aspera-cli 4.26.2 → 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 (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  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 +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -8,8 +8,9 @@ require 'aspera/secret_hider'
8
8
  require 'aspera/log'
9
9
  require 'aspera/assert'
10
10
  require 'aspera/dot_container'
11
+ require 'aspera/schema/registry'
11
12
  require 'io/console'
12
- require 'optparse'
13
+ require 'terminal-table'
13
14
 
14
15
  module Aspera
15
16
  module Cli
@@ -40,7 +41,7 @@ module Aspera
40
41
  # `false` and `true`
41
42
  TYPES = [FalseClass, TrueClass].freeze
42
43
  SYMBOLS = [NO_SYM, YES_SYM].freeze
43
- # @return `true` if value is a value for `true` in ALL
44
+ # @return [Boolean] `true` if value is a value for `true` in ALL
44
45
  def true?(enum)
45
46
  Aspera.assert_values(enum, ALL){'boolean'}
46
47
  TRUE_VALUES.include?(enum)
@@ -52,41 +53,49 @@ module Aspera
52
53
  TRUE_VALUES.include?(enum) ? YES_SYM : NO_SYM
53
54
  end
54
55
 
55
- # @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
56
57
  def symbol?(sym)
57
58
  ALL.include?(sym)
58
59
  end
59
60
  module_function :true?, :to_sym, :symbol?
60
61
  end
61
62
 
62
- # 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
63
68
  module Allowed
64
69
  # This option can be set to a single string or array, multiple times, and gives Array of String
65
70
  TYPES_STRING_ARRAY = [Array, String].freeze
66
- # A list of symbols with constrained values
71
+ # A list of symbols with constrained values; use as prefix: TYPES_SYMBOL_ARRAY + [:val1, :val2]
67
72
  TYPES_SYMBOL_ARRAY = [Array, Symbol].freeze
68
73
  # Value will be coerced to int
69
74
  TYPES_INTEGER = [Integer].freeze
70
75
  TYPES_BOOLEAN = BoolValue::TYPES
71
76
  # No value at all for the option, it's a switch, like `-N`
72
77
  TYPES_NONE = [].freeze
73
- # Symbol
74
- TYPES_ENUM = [Symbol].freeze
75
- # 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
76
81
  TYPES_STRING = [String].freeze
77
82
  end
78
83
 
79
84
  # Description of option, how to manage
80
85
  class OptionValue
81
86
  # [Array(Class)] List of allowed types
82
- attr_reader :types, :sensitive, :schema, :option
87
+ attr_reader :types, :sensitive, :schema, :option, :deprecation
83
88
  # [Array] List of allowed values (Symbols and specific values)
84
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
85
94
 
86
95
  # @param option [Symbol] Name of option
87
- # @param description [String] Description for help
96
+ # @param description [String, nil] Description for help; if nil, derived from schema
88
97
  # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
89
- # @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
90
99
  # @param deprecation [String] Deprecation message
91
100
  # @param schema [String] Declaration of schema
92
101
  # `allowed`:
@@ -94,28 +103,34 @@ module Aspera
94
103
  # - `Class` The single allowed Class
95
104
  # - `Array<Class>` Multiple allowed classes
96
105
  # - `Array<Symbol>` List of allowed values
97
- 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)
98
107
  Log.log.trace1{"option: #{option}, allowed: #{allowed}"}
99
108
  @option = option
100
109
  @description = description
110
+ @group = nil
111
+ @block = nil
101
112
  # by default passwords and secrets are sensitive, else specify when declaring the option
102
113
  @sensitive = SecretHider.instance.secret?(@option, '')
103
- # either the value, or object giving value
104
- @object = handler&.[](:o)
105
- @read_method = handler&.[](:m)
106
- @write_method = @read_method ? "#{@read_method}=".to_sym : nil
107
114
  @deprecation = deprecation
108
115
  @schema = schema
109
- @access = if @object.nil?
110
- :local
111
- elsif @object.respond_to?(@write_method)
112
- :write
113
- else
114
- :setter
115
- end
116
- 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?
117
122
  @types = nil
118
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
119
134
  if !allowed.nil?
120
135
  allowed = [allowed] if allowed.is_a?(Class)
121
136
  Aspera.assert_type(allowed, Array)
@@ -123,12 +138,17 @@ module Aspera
123
138
  # Special case: array of defined symbol values
124
139
  @types = Allowed::TYPES_SYMBOL_ARRAY
125
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?
126
143
  elsif allowed.all?(Class)
127
144
  @types = allowed
128
145
  @values = BoolValue::ALL if allowed.eql?(Allowed::TYPES_BOOLEAN)
129
- # Default value for array
130
- @object ||= [] if @types.first.eql?(Array) && !@types.include?(NilClass)
131
- @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
132
152
  elsif allowed.all?(Symbol)
133
153
  @types = Allowed::TYPES_ENUM
134
154
  @values = allowed
@@ -139,6 +159,40 @@ module Aspera
139
159
  Log.log.trace1{"declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green}
140
160
  end
141
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
+
142
196
  def clear
143
197
  @object = nil
144
198
  end
@@ -158,22 +212,42 @@ module Aspera
158
212
  # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
159
213
  # @param value [String, Object] Value to assign to option
160
214
  # @param where [String] Where the value is assigned from
215
+ # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
161
216
  # @return [nil]
162
- def assign_value(value, where:)
163
- 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
164
219
  new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
165
220
  Log.log.trace1{"#{where}: #{@option} <- (#{new_value.class})#{new_value}"}
166
- new_value = BoolValue.true?(new_value) if @types.eql?(Allowed::TYPES_BOOLEAN)
167
- new_value = Integer(new_value) if @types.eql?(Allowed::TYPES_INTEGER)
168
- new_value = [new_value] if @types.eql?(Allowed::TYPES_STRING_ARRAY) && new_value.is_a?(String)
169
- # Setting a Hash to null set an empty hash
170
- new_value = {} if new_value.eql?(nil) && @types&.first.eql?(Hash)
171
- # Setting a Array to null set an empty array
172
- new_value = [] if new_value.eql?(nil) && @types&.first.eql?(Array)
173
- 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
174
234
  new_value = [new_value] if new_value.is_a?(String)
175
235
  Aspera.assert_array_all(new_value, String, type: BadArgument)
176
- 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
177
251
  end
178
252
  Aspera.assert_type(new_value, *@types, type: BadArgument){"Option #{@option}"} if @types
179
253
  if new_value.is_a?(Hash) || new_value.is_a?(Array)
@@ -194,7 +268,7 @@ module Aspera
194
268
  # parse command line options
195
269
  # arguments options start with '-', others are commands
196
270
  # resolves on extended value syntax
197
- class Manager
271
+ class Parser
198
272
  class << self
199
273
  # Find shortened string value in allowed symbol list
200
274
  def get_from_list(short_value, descr, allowed_values)
@@ -203,12 +277,21 @@ module Aspera
203
277
  matching_exact = allowed_values.select{ |i| i.to_s.eql?(short_value)}
204
278
  return matching_exact.first if matching_exact.length == 1
205
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)
206
281
  Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
207
282
  Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
208
283
  return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
209
284
  matching.first
210
285
  end
211
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
+
212
295
  # Generates error message with list of allowed values
213
296
  # @param error_msg [String] Error message
214
297
  # @param accept_list [Array<Symbol>] List of allowed values
@@ -217,8 +300,8 @@ module Aspera
217
300
  end
218
301
 
219
302
  # Change option name with dash to name with underscore
220
- # @param name [String] option name
221
- # @return [String]
303
+ # @param name [String] option name with dash separators
304
+ # @return [String] option name with underscore separators
222
305
  def option_line_to_name(name)
223
306
  name.gsub(OPTION_SEP_LINE, OPTION_SEP_SYMBOL)
224
307
  end
@@ -237,19 +320,12 @@ module Aspera
237
320
  end
238
321
  end
239
322
 
240
- attr_reader :parser
241
- attr_accessor :ask_missing_mandatory, :ask_missing_optional
323
+ attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
242
324
  attr_writer :fail_on_missing_mandatory
243
325
 
244
326
  # @param program_name [String] Name of the program
245
327
  # @param argv [Array<String>, nil] Command line arguments to parse
246
328
  def initialize(program_name, argv = nil)
247
- # command line values *not* starting with '-'
248
- @unprocessed_cmd_line_arguments = []
249
- # command line values starting with at least one '-'
250
- @unprocessed_cmd_line_options = []
251
- # a copy of all initial options
252
- @initial_cli_options = []
253
329
  # Option descriptions: maps option symbol to its OptionValue descriptor
254
330
  # @type [Hash{Symbol => OptionValue}]
255
331
  @declared_options = {}
@@ -259,45 +335,57 @@ module Aspera
259
335
  @ask_missing_optional = false
260
336
  # get_option fails if a mandatory parameter is asked
261
337
  @fail_on_missing_mandatory = true
262
- # Array of [key(sym), value]
263
- # those must be set before parse
264
- # 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
265
341
  @option_pairs_batch = {}
266
342
  @option_pairs_env = {}
267
- # NOTE: was initially inherited but it is preferred to have specific methods
268
- @parser = OptionParser.new
269
- @parser.program_name = program_name
270
- # 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'
271
347
  env_prefix = program_name.upcase + OPTION_SEP_SYMBOL
272
348
  ENV.each do |k, v|
273
349
  @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
274
350
  end
275
351
  Log.log.debug{"env=#{@option_pairs_env}".red}
352
+ # command line values starting with at least one '-'
276
353
  @unprocessed_cmd_line_options = []
354
+ # command line values *not* starting with '-'
277
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 = {}
278
362
  return if argv.nil?
279
363
  # true until `--` is found (stop options)
280
364
  process_options = true
281
- until argv.empty?
282
- value = argv.shift
365
+ arg_count = 0
366
+ argv.each do |value|
283
367
  if process_options && value.start_with?('-')
284
368
  Log.log.trace1{"opt: #{value}"}
285
369
  if value.eql?(OPTIONS_STOP)
286
370
  process_options = false
287
371
  else
288
372
  @unprocessed_cmd_line_options.push(value)
373
+ (@args_before_option[value] ||= []).push(arg_count)
289
374
  end
290
375
  else
291
376
  Log.log.trace1{"arg: #{value}"}
292
377
  @unprocessed_cmd_line_arguments.push(value)
378
+ arg_count += 1
293
379
  end
294
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
295
385
  @initial_cli_options = @unprocessed_cmd_line_options.dup.freeze
296
386
  Log.log.trace1{"add_cmd_line_options:commands/arguments=#{@unprocessed_cmd_line_arguments},options=#{@unprocessed_cmd_line_options}".red}
297
- @parser.separator('')
298
- @parser.separator('OPTIONS: global')
299
- declare(:interactive, 'Use interactive input of missing params', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
300
- 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})
301
389
  # do not parse options yet, let's wait for option `-h` to be overridden
302
390
  end
303
391
 
@@ -311,7 +399,7 @@ module Aspera
311
399
 
312
400
  # Declare an option
313
401
  # @param option_symbol [Symbol] option name
314
- # @param description [String] description for help
402
+ # @param description [String, nil] description for help; if nil, derived from schema
315
403
  # @param short [String] short option name
316
404
  # @param allowed [Object] Allowed values, see `OptionValue`
317
405
  # @param default [Object] default value
@@ -319,12 +407,9 @@ module Aspera
319
407
  # @param deprecation [String] deprecation
320
408
  # @param schema [String] Definition of schema for Hash parameters
321
409
  # @param block [Proc] Block to execute when option is found
322
- 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)
323
411
  Aspera.assert_type(option_symbol, Symbol)
324
412
  Aspera.assert(!@declared_options.key?(option_symbol)){"#{option_symbol} already declared"}
325
- Aspera.assert(description[-1] != '.'){"#{option_symbol} ends with dot"}
326
- Aspera.assert(description[0] == description[0].upcase){"#{option_symbol} description does not start with an uppercase"}
327
- Aspera.assert(!['hash', 'extended value'].any?{ |s| description.downcase.include?(s)}){"#{option_symbol} shall use :allowed instead of hash/extended value in option description"}
328
413
  Aspera.assert_type(handler, Hash) if handler
329
414
  Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
330
415
  option_attrs = @declared_options[option_symbol] = OptionValue.new(
@@ -335,45 +420,44 @@ module Aspera
335
420
  deprecation: deprecation,
336
421
  schema: schema
337
422
  )
338
- real_types = option_attrs.types&.reject{ |i| [NilClass, String, Symbol].include?(i)}
339
- description += add_types_info(real_types)
340
- 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"}
341
429
  set_option(option_symbol, default, where: 'default') unless default.nil?
342
- on_args = [description]
343
430
  case option_attrs.types
344
431
  when Allowed::TYPES_ENUM, Allowed::TYPES_BOOLEAN
345
432
  # This option value must be a symbol (or array of symbols)
346
433
  set_option(option_symbol, BoolValue.true?(default), where: 'default') if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
347
- value = get_option(option_symbol)
348
- help_values =
349
- if option_attrs.types.eql?(Allowed::TYPES_BOOLEAN)
350
- highlight_current_in_list(BoolValue::SYMBOLS, BoolValue.to_sym(value))
351
- else
352
- highlight_current_in_list(option_attrs.values, value)
353
- end
354
- on_args[0] = "#{description}: #{help_values}"
355
- on_args.push(symbol_to_option(option_symbol, 'ENUM'))
356
- # on_args.push(option_attrs.values)
357
- @parser.on(*on_args) do |v|
358
- set_option(option_symbol, self.class.get_from_list(v.to_s, description, option_attrs.values), where: SOURCE_USER)
359
- end
360
434
  when Allowed::TYPES_NONE
361
435
  Aspera.assert_type(block, Proc){"missing execution block for #{option_symbol}"}
362
- on_args.push(symbol_to_option(option_symbol))
363
- on_args.push("-#{short}") if short.is_a?(String)
364
- @parser.on(*on_args, &block)
365
- else
366
- on_args.push(symbol_to_option(option_symbol, 'VALUE'))
367
- on_args.push("-#{short}VALUE") unless short.nil?
368
- # coerce integer
369
- on_args.push(Integer) if option_attrs.types.eql?(Allowed::TYPES_INTEGER)
370
- @parser.on(*on_args) do |v|
371
- set_option(option_symbol, v, where: SOURCE_USER)
372
- end
436
+ option_attrs.block = block
373
437
  end
374
- 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
375
455
  end
376
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
377
461
  # @param descr [String] description for help
378
462
  # @param mandatory [Boolean] `true`: raise error no more argument
379
463
  # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
@@ -381,7 +465,7 @@ module Aspera
381
465
  # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
382
466
  # @param aliases [Hash] map of aliases: key = alias, value = real value
383
467
  # @param default [Object] default value
384
- # @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)
385
469
  def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Allowed::TYPES_STRING, aliases: nil, default: nil, schema: nil)
386
470
  Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
387
471
  Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
@@ -443,14 +527,14 @@ module Aspera
443
527
  #
444
528
  # @param description [String] description of the identifier
445
529
  # @param block [Proc] block to search for identifier based on attribute value
446
- # @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)
447
531
  # @yieldparam field [String] The field name from percent selector
448
532
  # @yieldparam value [String] The value from percent selector
449
533
  # @yieldreturn [String] Resolved identifier
450
534
  def instance_identifier(description: 'identifier', &block)
451
- 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))
452
536
  # Can be an Array
453
- 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))
454
538
  Aspera.assert(block_given?, type: Cli::BadArgument){"Percent syntax for #{description} not supported in this context"}
455
539
  res_id = yield(m[:field], m[:value])
456
540
  end
@@ -459,8 +543,18 @@ module Aspera
459
543
 
460
544
  def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
461
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
+
462
556
  # Get an option definition by name
463
- # @param option_symbol [Symbol]
557
+ # @param option_symbol [Symbol] name of the option
464
558
  # @return [OptionValue] Option definition
465
559
  # @raise [Cli::BadArgument] if option not found
466
560
  def option_def(option_symbol)
@@ -471,12 +565,16 @@ module Aspera
471
565
  # Get an option value by name
472
566
  # either return value or calls handler, can return nil
473
567
  # ask interactively if requested/required
474
- # @param option_symbol [Symbol]
568
+ # @param option_symbol [Symbol] name of the option to retrieve
475
569
  # @param mandatory [Boolean] if true, raise error if option not set
476
- 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)
477
573
  Aspera.assert_type(option_symbol, Symbol)
478
574
  option_attrs = option_def(option_symbol)
479
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)
480
578
  # Do not fail for manual generation if option mandatory but not set
481
579
  return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
482
580
  if result.nil?
@@ -499,7 +597,10 @@ module Aspera
499
597
  def set_option(option_symbol, value, where: 'code override')
500
598
  Aspera.assert_type(option_symbol, Symbol)
501
599
  option = option_def(option_symbol)
502
- 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
503
604
  option.assign_value(value, where: where)
504
605
  end
505
606
 
@@ -509,6 +610,18 @@ module Aspera
509
610
  option_def(option_symbol).clear
510
611
  end
511
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
+
512
625
  # Adds each of the keys of specified hash as an option
513
626
  # @param preset_hash [Hash] Options to add
514
627
  # @param where [String] Where the value comes from
@@ -576,37 +689,52 @@ module Aspera
576
689
  def parse_options!
577
690
  Log.log.trace1('parse_options!'.red)
578
691
  # First options from conf file
579
- consume_option_pairs(@option_pairs_batch, 'set')
692
+ @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
580
693
  # Then, env var (to override)
581
- consume_option_pairs(@option_pairs_env, 'env')
582
- # 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).
583
697
  unknown_options = []
584
- begin
585
- # remove known options one by one, exception if unknown
586
- Log.log.trace1('Before parse')
587
- Log.dump(:unprocessed_cmd_line_options, @unprocessed_cmd_line_options, level: :trace1)
588
- @parser.parse!(@unprocessed_cmd_line_options)
589
- Log.log.trace1('After parse')
590
- rescue OptionParser::InvalidOption => e
591
- Log.log.trace1{"InvalidOption #{e}".red}
592
- # An option like --a.b.c=d does: a={"b":{"c":ext_val(d)}}
593
- if e.args.first.start_with?(OPTION_PREFIX)
594
- name, value = e.args.first.delete_prefix(OPTION_PREFIX).split(OPTION_VALUE_SEPARATOR, 2)
595
- if !value.nil?
596
- path = name.split(DotContainer::SEPARATOR)
597
- option_sym = self.class.option_line_to_name(path.shift).to_sym
598
- if @declared_options.key?(option_sym)
599
- # it's a known option, so let's process it
600
- set_option(option_sym, DotContainer.dotted_to_container(path, smart_convert(value), get_option(option_sym)), where: 'dotted')
601
- # resume to next
602
- 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
603
724
  end
725
+ # Unknown option: defer to next parse_options! round, do not consume the recorded offset
726
+ unknown_options.push(opt)
604
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)
605
734
  end
606
- # Save for later processing
607
- unknown_options.push(e.args.first)
608
- retry
609
735
  end
736
+ @current_option_args_offset = nil
737
+ Log.log.trace1('After parse')
610
738
  Log.log.trace1{"remains: #{unknown_options}"}
611
739
  # Set unprocessed options for next time
612
740
  @unprocessed_cmd_line_options = unknown_options
@@ -674,21 +802,85 @@ module Aspera
674
802
  # This extended value does not take args (`@:`)
675
803
  # ExtendedValue.assert_no_value(end_marker, :p)
676
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?
677
815
  result = nil
678
816
  get_next_argument('args', multiple: end_marker).each do |argument|
679
817
  Aspera.assert(argument.include?(OPTION_VALUE_SEPARATOR)){"Positional argument: #{argument} does not include #{OPTION_VALUE_SEPARATOR}"}
680
818
  path, value = argument.split(OPTION_VALUE_SEPARATOR, 2)
681
819
  result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), smart_convert(value), result)
682
820
  end
821
+ # Restore skipped args so they remain available for command dispatching
822
+ @unprocessed_cmd_line_arguments.unshift(*skipped) unless skipped.empty?
683
823
  result
684
824
  end
685
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
+
686
846
  # ======================================================
687
847
  private
688
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
+
689
881
  # Using dotted hash notation, convert value to bool, int, float or extended value
690
882
  # @param value [String] The value to convert to appropriate type
691
- # @return the converted value
883
+ # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
692
884
  def smart_convert(value)
693
885
  case value
694
886
  when 'true' then true
@@ -725,23 +917,23 @@ module Aspera
725
917
  # Try to evaluate options set in batch
726
918
  # @param unprocessed_options [Array] list of options to apply (key_sym,value)
727
919
  # @param where [String] where the options come from
728
- 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)
729
926
  Log.log.trace1{"consume_option_pairs: #{where}"}
730
- options_to_set = {}
731
- unprocessed_options.each do |k, v|
927
+ remaining = {}
928
+ option_pairs.each do |k, v|
732
929
  if @declared_options.key?(k)
733
- # constrained parameters as string are revert to symbol
734
- v = self.class.get_from_list(v, "#{k} in #{where}", @declared_options[k].values) if @declared_options[k].values && v.is_a?(String)
735
- options_to_set[k] = v
930
+ set_option(k, v, where: where)
736
931
  else
737
932
  Log.log.trace1{"unprocessed: #{k}: #{v}"}
933
+ remaining[k] = v
738
934
  end
739
935
  end
740
- options_to_set.each do |k, v|
741
- set_option(k, v, where: where)
742
- # keep only unprocessed values for next parse
743
- unprocessed_options.delete(k)
744
- end
936
+ remaining
745
937
  end
746
938
 
747
939
  # Option name separator on command line, e.g. in --option-blah, third "-"
@@ -760,7 +952,7 @@ module Aspera
760
952
  # Ask for schema of Extended value
761
953
  HELP = 'help'
762
954
 
763
- 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
764
956
  end
765
957
  end
766
958
  end