aspera-cli 4.26.2 → 4.27.1

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 (159) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +310 -21
  4. data/CONTRIBUTING.md +99 -14
  5. data/TODO.md +50 -0
  6. data/bin/ascli +3 -3
  7. data/docs/README.md +10977 -0
  8. data/docs/test-mcp-with-ai.md +202 -0
  9. data/lib/aspera/agent/base.rb +29 -7
  10. data/lib/aspera/agent/connect.rb +58 -21
  11. data/lib/aspera/agent/desktop.rb +53 -18
  12. data/lib/aspera/agent/direct.rb +71 -40
  13. data/lib/aspera/agent/factory.rb +3 -3
  14. data/lib/aspera/agent/httpgw.rb +55 -14
  15. data/lib/aspera/agent/node.rb +34 -2
  16. data/lib/aspera/agent/transferd.rb +48 -12
  17. data/lib/aspera/api/alee.rb +1 -1
  18. data/lib/aspera/api/aoc.rb +42 -44
  19. data/lib/aspera/api/cos_node.rb +10 -9
  20. data/lib/aspera/api/faspex.rb +16 -15
  21. data/lib/aspera/api/httpgw.rb +23 -23
  22. data/lib/aspera/api/node.rb +51 -70
  23. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  24. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  25. data/lib/aspera/ascmd.rb +32 -30
  26. data/lib/aspera/ascp/installation.rb +75 -69
  27. data/lib/aspera/ascp/management.rb +7 -8
  28. data/lib/aspera/assert.rb +12 -13
  29. data/lib/aspera/cli/ascp_actions.rb +155 -0
  30. data/lib/aspera/cli/async_transfer_store.rb +125 -0
  31. data/lib/aspera/cli/bootstrapper.rb +197 -0
  32. data/lib/aspera/cli/command_registry.rb +145 -0
  33. data/lib/aspera/cli/command_spec.rb +141 -0
  34. data/lib/aspera/cli/context.rb +19 -6
  35. data/lib/aspera/cli/error.rb +16 -0
  36. data/lib/aspera/cli/extended_value.rb +27 -26
  37. data/lib/aspera/cli/formatter.rb +95 -59
  38. data/lib/aspera/cli/gem_checker.rb +65 -0
  39. data/lib/aspera/cli/hints.rb +3 -3
  40. data/lib/aspera/cli/http.rb +74 -30
  41. data/lib/aspera/cli/info.rb +2 -0
  42. data/lib/aspera/cli/mailer.rb +97 -0
  43. data/lib/aspera/cli/mcp_tool.rb +225 -0
  44. data/lib/aspera/cli/option_declarator.rb +77 -0
  45. data/lib/aspera/cli/options.schema.yaml +634 -7
  46. data/lib/aspera/cli/parser.rb +1217 -0
  47. data/lib/aspera/cli/plugins/alee.rb +20 -22
  48. data/lib/aspera/cli/plugins/aoc.rb +1178 -881
  49. data/lib/aspera/cli/plugins/ats.rb +205 -162
  50. data/lib/aspera/cli/plugins/base.rb +703 -175
  51. data/lib/aspera/cli/plugins/basic_auth.rb +7 -9
  52. data/lib/aspera/cli/plugins/config.rb +434 -757
  53. data/lib/aspera/cli/plugins/console.rb +107 -65
  54. data/lib/aspera/cli/plugins/cos.rb +46 -33
  55. data/lib/aspera/cli/plugins/factory.rb +6 -6
  56. data/lib/aspera/cli/plugins/faspex5.rb +627 -388
  57. data/lib/aspera/cli/plugins/faspio.rb +54 -51
  58. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  59. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  60. data/lib/aspera/cli/plugins/node.rb +988 -840
  61. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  62. data/lib/aspera/cli/plugins/orchestrator.rb +112 -135
  63. data/lib/aspera/cli/plugins/preview.rb +195 -157
  64. data/lib/aspera/cli/plugins/server.rb +141 -91
  65. data/lib/aspera/cli/plugins/shares.rb +343 -110
  66. data/lib/aspera/cli/preset_actions.rb +159 -0
  67. data/lib/aspera/cli/preset_manager.rb +82 -39
  68. data/lib/aspera/cli/result.rb +78 -26
  69. data/lib/aspera/cli/runner.rb +323 -153
  70. data/lib/aspera/cli/secret_finder.rb +40 -0
  71. data/lib/aspera/cli/special_values.rb +1 -0
  72. data/lib/aspera/cli/sync_actions.rb +89 -64
  73. data/lib/aspera/cli/terminal_formatter.rb +3 -3
  74. data/lib/aspera/cli/transfer_actions.rb +90 -0
  75. data/lib/aspera/cli/transfer_agent.rb +129 -64
  76. data/lib/aspera/cli/transfer_progress.rb +9 -9
  77. data/lib/aspera/cli/vault_manager.rb +79 -0
  78. data/lib/aspera/cli/version.rb +1 -1
  79. data/lib/aspera/cli/wizard.rb +28 -30
  80. data/lib/aspera/colors.rb +3 -3
  81. data/lib/aspera/command_line_builder.rb +34 -34
  82. data/lib/aspera/command_line_converter.rb +1 -1
  83. data/lib/aspera/coverage.rb +1 -2
  84. data/lib/aspera/data_repository.rb +2 -1
  85. data/lib/aspera/dot_container.rb +12 -12
  86. data/lib/aspera/environment.rb +30 -23
  87. data/lib/aspera/exec_spec.rb +13 -0
  88. data/lib/aspera/faspex_gw.rb +5 -5
  89. data/lib/aspera/faspex_postproc.rb +16 -10
  90. data/lib/aspera/graphql.rb +37 -0
  91. data/lib/aspera/hash_ext.rb +8 -2
  92. data/lib/aspera/json_rpc/client.rb +62 -0
  93. data/lib/aspera/json_rpc/version.rb +7 -0
  94. data/lib/aspera/keychain/base.rb +10 -3
  95. data/lib/aspera/keychain/encrypted_hash.rb +92 -17
  96. data/lib/aspera/keychain/factory.rb +20 -8
  97. data/lib/aspera/keychain/hashicorp_vault.rb +1 -1
  98. data/lib/aspera/keychain/macos_security.rb +23 -25
  99. data/lib/aspera/keychain/one_password_api.rb +86 -0
  100. data/lib/aspera/keychain/one_password_base.rb +34 -0
  101. data/lib/aspera/keychain/one_password_cli.rb +98 -0
  102. data/lib/aspera/link_header.rb +82 -0
  103. data/lib/aspera/log.rb +29 -15
  104. data/lib/aspera/markdown.rb +90 -7
  105. data/lib/aspera/nagios.rb +8 -8
  106. data/lib/aspera/node_simulator.rb +35 -27
  107. data/lib/aspera/oauth/base.rb +9 -10
  108. data/lib/aspera/oauth/boot.rb +5 -5
  109. data/lib/aspera/oauth/factory.rb +12 -10
  110. data/lib/aspera/oauth/jwt.rb +9 -11
  111. data/lib/aspera/oauth/web.rb +6 -6
  112. data/lib/aspera/persistency_action_once.rb +12 -12
  113. data/lib/aspera/persistency_folder.rb +18 -11
  114. data/lib/aspera/preview/file_types.rb +5 -5
  115. data/lib/aspera/preview/generator.rb +52 -49
  116. data/lib/aspera/preview/options.rb +3 -2
  117. data/lib/aspera/preview/terminal.rb +5 -5
  118. data/lib/aspera/preview/utils.rb +66 -19
  119. data/lib/aspera/products/connect.rb +2 -2
  120. data/lib/aspera/products/desktop.rb +1 -1
  121. data/lib/aspera/products/other.rb +3 -3
  122. data/lib/aspera/products/transferd.rb +4 -4
  123. data/lib/aspera/proxy_auto_config.rb +15 -14
  124. data/lib/aspera/rest.rb +159 -151
  125. data/lib/aspera/rest_error_analyzer.rb +6 -6
  126. data/lib/aspera/rest_errors_aspera.rb +0 -10
  127. data/lib/aspera/rest_list.rb +11 -10
  128. data/lib/aspera/schema/IBM Aspera Node API-4.4.6.yaml +6232 -0
  129. data/lib/aspera/schema/IBM Aspera faspio Gateway API-1.0.0.yaml +249 -0
  130. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  131. data/lib/aspera/schema/IBM_Aspera_Shares.yaml +4653 -0
  132. data/lib/aspera/schema/documentation.rb +65 -28
  133. data/lib/aspera/schema/reader.rb +136 -13
  134. data/lib/aspera/schema/registry.rb +60 -8
  135. data/lib/aspera/secret_hider.rb +12 -7
  136. data/lib/aspera/ssh.rb +64 -31
  137. data/lib/aspera/ssl.rb +5 -5
  138. data/lib/aspera/sync/conf.schema.yaml +2 -2
  139. data/lib/aspera/sync/database.rb +2 -2
  140. data/lib/aspera/sync/operations.rb +22 -25
  141. data/lib/aspera/temp_file_manager.rb +25 -5
  142. data/lib/aspera/timer_limiter.rb +1 -1
  143. data/lib/aspera/transfer/faux_file.rb +24 -11
  144. data/lib/aspera/transfer/parameters.rb +36 -34
  145. data/lib/aspera/transfer/result.rb +74 -0
  146. data/lib/aspera/transfer/resumer.rb +10 -10
  147. data/lib/aspera/transfer/spec.rb +18 -1
  148. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  149. data/lib/aspera/uri_reader.rb +52 -15
  150. data/lib/aspera/web_auth.rb +8 -7
  151. data/lib/aspera/web_server_simple.rb +15 -12
  152. data/lib/aspera/yaml.rb +5 -4
  153. data.tar.gz.sig +0 -0
  154. metadata +34 -6
  155. metadata.gz.sig +0 -0
  156. data/lib/aspera/cli/manager.rb +0 -766
  157. data/lib/aspera/cli/plugins/faspex.rb +0 -539
  158. data/lib/aspera/json_rpc.rb +0 -52
  159. data/lib/aspera/transfer/uri.rb +0 -56
@@ -0,0 +1,1217 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/cli/preset_manager'
4
+ require 'aspera/cli/extended_value'
5
+ require 'aspera/cli/error'
6
+ require 'aspera/cli/special_values'
7
+ require 'aspera/cli/terminal_formatter'
8
+ require 'aspera/colors'
9
+ require 'aspera/secret_hider'
10
+ require 'aspera/log'
11
+ require 'aspera/assert'
12
+ require 'aspera/dot_container'
13
+ require 'aspera/schema/registry'
14
+ require 'io/console'
15
+ require 'terminal-table'
16
+
17
+ module Aspera
18
+ module Cli
19
+ # Exception raised when schema is asked (`help`)
20
+ class SchemaRequest < Error
21
+ # @return [String, nil] path to schema file
22
+ attr_reader :path
23
+
24
+ # @param type [Symbol] :argument or :option
25
+ # @param name [String] name of the option/argument
26
+ # @param schema_path [String, nil] path to schema file, or `nil` if not available
27
+ def initialize(type, name, schema_path)
28
+ super("#{type}: #{name}")
29
+ @path = schema_path
30
+ end
31
+ end
32
+
33
+ module BoolValue
34
+ # boolean options are set to true/false from the following values
35
+ YES_SYM = :yes
36
+ NO_SYM = :no
37
+ FALSE_VALUES = [NO_SYM, false].freeze
38
+ TRUE_VALUES = [YES_SYM, true].freeze
39
+ private_constant :YES_SYM, :NO_SYM, :FALSE_VALUES, :TRUE_VALUES
40
+ # Boolean values
41
+ # @return [Array<true, false, :yes, :no>]
42
+ ALL = (TRUE_VALUES + FALSE_VALUES).freeze
43
+ # `false` and `true`
44
+ TYPES = [FalseClass, TrueClass].freeze
45
+ SYMBOLS = [NO_SYM, YES_SYM].freeze
46
+ # @return [Boolean] `true` if value is a value for `true` in ALL
47
+ def true?(enum)
48
+ Aspera.assert_values(enum, ALL) { 'boolean' }
49
+ TRUE_VALUES.include?(enum)
50
+ end
51
+
52
+ # @return [:yes, :no]
53
+ def to_sym(enum) = true?(enum) ? YES_SYM : NO_SYM
54
+
55
+ # @return [Boolean] `true` if value is a value for `true` or `false` in ALL
56
+ def symbol?(sym)
57
+ ALL.include?(sym)
58
+ end
59
+ module_function :true?, :to_sym, :symbol?
60
+ end
61
+
62
+ # Type specifiers for the `allowed:` parameter of option declarations.
63
+ # Public API: STRING_ARRAY, SYMBOL_ARRAY, INTEGER, BOOLEAN, NONE.
64
+ # Internal (do not pass as `allowed:`):
65
+ # ENUM - derived internally when `allowed:` is an Array<Symbol> (enum list)
66
+ # STRING - the implicit default; equivalent to omitting `allowed:` entirely
67
+ module Type
68
+ # Option value is a String or Array of Strings (cumulative)
69
+ STRING_ARRAY = [Array, String].freeze
70
+ # Option value is a Symbol from a constrained list; use as prefix: SYMBOL_ARRAY + [:val1, :val2]
71
+ SYMBOL_ARRAY = [Array, Symbol].freeze
72
+ # Option value is coerced to Integer
73
+ INTEGER = [Integer].freeze
74
+ # Option value is a Boolean
75
+ BOOLEAN = BoolValue::TYPES
76
+ # Option has no value — it is a flag switch (e.g. `-N`, `--help`)
77
+ NONE = [].freeze
78
+ # Internal: derived when allowed: is an Array<Symbol>; do not pass directly
79
+ ENUM = [Symbol].freeze
80
+ # Internal: implicit default (String); equivalent to omitting allowed: entirely
81
+ STRING = [String].freeze
82
+ end
83
+
84
+ # Description of option, how to manage
85
+ class OptionValue
86
+ # [Array(Class)] List of allowed types
87
+ attr_reader :types, :sensitive, :schema, :option, :deprecation
88
+ # [Array] List of allowed values (Symbols and specific values)
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
94
+
95
+ # @param option [Symbol] Name of option
96
+ # @param description [String, nil] Description for help; if nil, derived from schema
97
+ # @param allowed [nil,Class,Array<Class>,Array<Symbol>] Allowed values
98
+ # @param handler [Hash, nil] Accessor: keys: :o(object) and :m(method); nil for local storage
99
+ # @param deprecation [String] Deprecation message
100
+ # @param schema [String] Declaration of schema
101
+ # `allowed`:
102
+ # - `nil` No validation, so just a string
103
+ # - `Class` The single allowed Class
104
+ # - `Array<Class>` Multiple allowed classes
105
+ # - `Array<Symbol>` List of allowed values
106
+ def initialize(option:, description: nil, allowed: Type::STRING, handler: nil, deprecation: nil, schema: nil)
107
+ Log.log.trace1 { "option: #{option}, allowed: #{allowed}" }
108
+ @option = option
109
+ @description = description
110
+ @group = nil
111
+ @block = nil
112
+ # by default passwords and secrets are sensitive, else specify when declaring the option
113
+ @sensitive = SecretHider.instance.secret?(@option, '')
114
+ @deprecation = deprecation
115
+ @schema = schema
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?
122
+ @types = nil
123
+ @values = nil
124
+ allowed = infer_allowed_from_schema(schema, allowed) if schema
125
+ apply_allowed(allowed) unless allowed.nil?
126
+ Log.log.trace1 { "declare: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
127
+ end
128
+
129
+ private
130
+
131
+ # Derive the `allowed:` value from the schema when not explicitly provided.
132
+ # Returns `allowed` unchanged when the schema provides no usable type information.
133
+ # @param schema [String] schema identifier
134
+ # @param allowed [Object] caller-supplied allowed value (may be nil or Type::STRING)
135
+ # @return [Object] resolved allowed (Hash, Array, or the original value)
136
+ def infer_allowed_from_schema(schema, allowed)
137
+ return allowed unless allowed.nil? || allowed.eql?(Type::STRING)
138
+
139
+ schema_reader = Schema::Registry.instance.reader(schema) rescue nil
140
+ schema_node = schema_reader&.current
141
+ return allowed unless schema_node
142
+
143
+ case schema_node['type']
144
+ when 'object' then return Hash
145
+ when 'array' then return Array
146
+ end
147
+ # No top-level type: inspect oneOf/anyOf branches; if all resolve to 'object', infer Hash
148
+ composite_key = (%w[oneOf anyOf] & schema_node.keys).first
149
+ return allowed unless composite_key
150
+
151
+ branch_types = schema_node[composite_key].map do |branch|
152
+ resolved = branch['$ref'] ? schema_reader.resolve_ref(branch['$ref']).current : branch
153
+ resolved['type']
154
+ end
155
+ if branch_types.all?('object')
156
+ Hash
157
+ else
158
+ Aspera.assert(
159
+ !allowed.nil? && !allowed.eql?(Type::STRING),
160
+ "option :#{@option}: schema '#{schema}' has mixed-type oneOf branches #{branch_types.uniq}: specify allowed: explicitly"
161
+ )
162
+ allowed
163
+ end
164
+ end
165
+
166
+ # Initialise @types and @values from the resolved `allowed` specifier.
167
+ # @param allowed [Class, Array<Class>, Array<Symbol>] resolved allowed value (never nil)
168
+ def apply_allowed(allowed)
169
+ allowed = [allowed] if allowed.is_a?(Class)
170
+ Aspera.assert_type(allowed, Array)
171
+ if allowed.take(Type::SYMBOL_ARRAY.length) == Type::SYMBOL_ARRAY
172
+ # Special case: array of defined symbol values
173
+ @types = Type::SYMBOL_ARRAY
174
+ @values = allowed[Type::SYMBOL_ARRAY.length..]
175
+ assign_value([], where: 'array default', warn_deprecation: false) if value(log: false).nil?
176
+ elsif allowed.all?(Class)
177
+ @types = allowed
178
+ @values = BoolValue::ALL if allowed.eql?(Type::BOOLEAN)
179
+ if @types.first.eql?(Array) && !@types.include?(NilClass) && value(log: false).nil?
180
+ assign_value([], where: 'array default', warn_deprecation: false)
181
+ elsif @types.first.eql?(Hash) && !@types.include?(NilClass) && value(log: false).nil?
182
+ assign_value({}, where: 'hash default', warn_deprecation: false)
183
+ end
184
+ elsif allowed.all?(Symbol)
185
+ @types = Type::ENUM
186
+ @values = allowed
187
+ else
188
+ Aspera.error_unexpected_value(allowed)
189
+ end
190
+ end
191
+
192
+ public
193
+
194
+ # Wire (or re-wire) the getter/setter delegation for this option.
195
+ # Safe to call after construction - used by Parser#set_handler to bind a composed
196
+ # instance variable that did not exist at class-load time (Category C handlers).
197
+ # @param handler [Hash] Accessor hash with keys :o (object) and :m (method symbol)
198
+ # @return [nil]
199
+ def bind_handler(handler)
200
+ Aspera.assert_type(handler, Hash) { 'handler' }
201
+ # Capture any value already stored locally before switching to delegated storage.
202
+ # This transfers defaults (and any preset values already applied) to the new target.
203
+ pending_value = @access.eql?(:local) ? @object : nil
204
+ @object = handler[:o]
205
+ @read_method = handler[:m]
206
+ @write_method = "#{@read_method}=".to_sym
207
+ @access = if @object.respond_to?(@write_method)
208
+ :attr # delegation via attr_accessor-style m / m=
209
+ else
210
+ :custom # delegation via generic 3-arg method m(sym, :get/:set, val)
211
+ end
212
+ Aspera.assert(@object.respond_to?(@read_method)) { "#{@object} does not respond to #{@read_method}" }
213
+ Log.log.trace1 { "bind_handler: #{@option}: #{@access} #{@object.class}.#{@read_method}".green }
214
+ # Push the pending local value to the new target if one was stored
215
+ assign_value(pending_value, where: 'bind_handler', warn_deprecation: false) unless pending_value.nil?
216
+ nil
217
+ end
218
+
219
+ # @return [String] description of the option: explicit one, or first line of schema description
220
+ def description
221
+ return @description unless @description.nil?
222
+ return if @schema.nil?
223
+ schema_node = Schema::Registry.instance.reader(@schema).current
224
+ first_line = (schema_node['title'] || schema_node['description'].to_s).lines.first.to_s.strip
225
+ first_line.end_with?('.') ? first_line[0..-2] : first_line
226
+ end
227
+
228
+ # Reset stored value to nil
229
+ # @return [nil]
230
+ def clear
231
+ @object = nil
232
+ end
233
+
234
+ # Get current option value
235
+ # @param log [Boolean] whether to log the value retrieval
236
+ # @return [Object] current value
237
+ def value(log: true)
238
+ current_value =
239
+ case @access
240
+ when :local then @object
241
+ when :attr then @object.send(@read_method)
242
+ when :custom then @object.send(@read_method, @option, :get)
243
+ end
244
+ Log.log.trace1 { "#{@option} -> (#{current_value.class})#{current_value}" } if log
245
+ current_value
246
+ end
247
+
248
+ # Assign value to option.
249
+ # Value can be a `String`, then evaluated with `ExtendedValue`, or directly a value.
250
+ # @param value [String, Object] Value to assign to option
251
+ # @param where [String] Where the value is assigned from
252
+ # @param warn_deprecation [Boolean] Emit deprecation warning (false for internal transfers)
253
+ # @return [nil]
254
+ def assign_value(value, where:, warn_deprecation: true)
255
+ Aspera.assert(!@deprecation, type: :warn) { "Option #{@option} is deprecated: #{@deprecation}" } if warn_deprecation
256
+ new_value = ExtendedValue.instance.evaluate(value, context: "option: #{@option}", allowed: @types)
257
+ Log.log.trace1 { "#{where}: #{@option} <- (#{new_value.class})#{new_value}" }
258
+ # Per-type coercion: String input from CLI/env/preset is normalized to the expected type.
259
+ # Centralized here so all sources (CLI dispatch, preset, env) go through the same path.
260
+ case @types
261
+ when Type::ENUM
262
+ new_value = Parser.get_from_list(new_value, @option, @values) if new_value.is_a?(String)
263
+ when Type::BOOLEAN
264
+ new_value = Parser.get_from_list(new_value, @option, BoolValue::ALL) if new_value.is_a?(String)
265
+ new_value = BoolValue.true?(new_value)
266
+ when Type::INTEGER
267
+ new_value = Integer(new_value)
268
+ when Type::STRING_ARRAY
269
+ new_value = [new_value] if new_value.is_a?(String)
270
+ when Type::SYMBOL_ARRAY
271
+ new_value = [new_value] if new_value.is_a?(String)
272
+ Aspera.assert_array_all(new_value, String, type: BadArgument)
273
+ new_value = new_value.map { |v| Parser.get_from_list(v, @option, @values) }
274
+ else
275
+ # nil (setting nil on a Hash/Array option resets to empty container)
276
+ new_value = {} if new_value.nil? && @types&.first.eql?(Hash)
277
+ new_value = [] if new_value.nil? && @types&.first.eql?(Array)
278
+ end
279
+ # Skip type validation for the special 'help' value on Hash options: store it as-is
280
+ # so that get_option(schema:) can raise SchemaRequest with the contextual schema later.
281
+ # Note: set_option already raises SchemaRequest when @schema is set, so this path is
282
+ # only reached when @schema is nil (e.g. --query=help before schema is known).
283
+ if new_value.eql?(Parser::HELP) && @types&.include?(Hash)
284
+ store(new_value)
285
+ return
286
+ end
287
+ Aspera.assert_type(new_value, *@types, type: BadArgument) { "Option #{@option}" } if @types
288
+ if new_value.is_a?(Hash) || new_value.is_a?(Array)
289
+ current_value = value(log: false)
290
+ new_value = current_value.deep_merge(new_value) if new_value.is_a?(Hash) && current_value.is_a?(Hash) && !current_value.empty?
291
+ new_value = current_value + new_value if new_value.is_a?(Array) && current_value.is_a?(Array) && !current_value.empty?
292
+ end
293
+ store(new_value)
294
+ Log.log.trace1 { v = value(log: false); "#{@option} <- (#{v.class})#{v}" } # rubocop:disable Style/Semicolon
295
+ nil
296
+ end
297
+
298
+ private
299
+
300
+ # Store value according to access mode
301
+ # @param new_value [Object] value to store
302
+ # @return [Object] stored value
303
+ def store(new_value)
304
+ case @access
305
+ when :local then @object = new_value
306
+ when :attr then @object.send(@write_method, new_value)
307
+ when :custom then @object.send(@read_method, @option, :set, new_value)
308
+ end
309
+ end
310
+ end
311
+
312
+ # Represents a positional (non-option) CLI argument token.
313
+ class Argument
314
+ # @return [String] the raw argument value
315
+ attr_reader :value
316
+
317
+ def initialize(value)
318
+ @value = value
319
+ end
320
+
321
+ def to_s = @value
322
+ end
323
+
324
+ # Represents a parsed CLI option token (long or short form).
325
+ # Pre-computed at argv-scan time; resolution against @declared_options happens later in parse_options!
326
+ class Option
327
+ # @return [String] raw token as it appeared in argv (e.g. "--log-level=debug", "-Pval")
328
+ attr_reader :raw
329
+ # @return [String, nil] option name with underscores (e.g. "log_level", "custom"); nil for short options
330
+ attr_reader :name
331
+ # @return [String, nil] single-char short option letter (e.g. "P"), nil for long options
332
+ attr_reader :short_char
333
+ # @return [Array<String>, nil] sub-keys for dot-path notation (e.g. ["field"] for --custom.field),
334
+ # nil when name is the full option name (no dot)
335
+ attr_reader :dot_path
336
+ # @return [String, nil] inline value string, or nil if no value was provided inline
337
+ attr_reader :value
338
+ # @return [Boolean] true if `=` (long) or glued value (short) was present in the raw token;
339
+ # false means no inline separator — value may come from the next argv token
340
+ attr_reader :has_value
341
+
342
+ # @param raw [String] full raw token
343
+ # @param name [String, nil] option name (underscored, no prefix)
344
+ # @param short_char [String, nil] single-char short letter
345
+ # @param dot_path [Array<String>, nil] dot-path sub-keys, or nil
346
+ # @param value [String, nil] inline value
347
+ # @param has_value [Boolean] whether an inline value separator was present
348
+ def initialize(raw:, name:, short_char:, dot_path:, value:, has_value:)
349
+ @raw = raw
350
+ @name = name
351
+ @short_char = short_char
352
+ @dot_path = dot_path
353
+ @value = value
354
+ @has_value = has_value
355
+ end
356
+
357
+ def to_s = @raw
358
+
359
+ class << self
360
+ # Build an Option from a raw long-option token (starts with `--`)
361
+ # @param raw [String] e.g. "--log-level=debug" or "--custom.field" or "--log-level"
362
+ def from_long(raw)
363
+ without_prefix = raw.delete_prefix(PREFIX)
364
+ eq_idx = without_prefix.index(VALUE_SEP)
365
+ if eq_idx
366
+ name_raw = without_prefix[0, eq_idx]
367
+ value = without_prefix[eq_idx + 1..]
368
+ has_value = true
369
+ else
370
+ name_raw = without_prefix
371
+ value = nil
372
+ has_value = false
373
+ end
374
+ parts = name_raw.split(DotContainer::SEPARATOR)
375
+ root = parts.shift.gsub(NAME_SEP_LINE, NAME_SEP_SYMBOL)
376
+ dot_path = parts.empty? ? nil : parts
377
+ new(raw: raw, name: root, short_char: nil, dot_path: dot_path, value: value, has_value: has_value)
378
+ end
379
+
380
+ # Build an Option from a raw short-option token (starts with `-` but not `--`)
381
+ # @param raw [String] e.g. "-P", "-Pval", "-h"
382
+ def from_short(raw)
383
+ short_char = raw[1]
384
+ if raw.length > 2
385
+ new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: raw[2..], has_value: true)
386
+ else
387
+ new(raw: raw, name: nil, short_char: short_char, dot_path: nil, value: nil, has_value: false)
388
+ end
389
+ end
390
+ end
391
+
392
+ # Option name separator on command line (e.g. `--option-name`, the `-` between words)
393
+ NAME_SEP_LINE = '-'
394
+ # Option name separator in code/symbol (e.g. `:option_name`, the `_` between words)
395
+ NAME_SEP_SYMBOL = '_'
396
+ # Separator between option name and its inline value (e.g. `--opt=val`, the `=`)
397
+ VALUE_SEP = '='
398
+ # Long-option prefix (e.g. `--opt`)
399
+ PREFIX = '--'
400
+ # (public: shared with Parser)
401
+ end
402
+
403
+ # parse command line options
404
+ # arguments options start with '-', others are commands
405
+ # resolves on extended value syntax
406
+ class Parser
407
+ class << self
408
+ # Find shortened string value in allowed symbol list
409
+ # @param short_value [String] value or prefix to find
410
+ # @param descr [String] description for error messages
411
+ # @param allowed_values [Array] list of allowed values
412
+ # @return [Symbol, Boolean] matched symbol or boolean value
413
+ def get_from_list(short_value, descr, allowed_values)
414
+ Aspera.assert_type(short_value, String)
415
+ # we accept shortcuts
416
+ matching_exact = allowed_values.select { |i| i.to_s.eql?(short_value) }
417
+ return matching_exact.first if matching_exact.length == 1
418
+ matching = allowed_values.select { |i| i.to_s.start_with?(short_value) }
419
+ 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)
420
+ Aspera.assert(!matching.empty?, multi_choice_assert_msg("unknown value for #{descr}: #{short_value}", allowed_values), type: BadArgument)
421
+ Aspera.assert(matching.length.eql?(1), multi_choice_assert_msg("ambiguous shortcut for #{descr}: #{short_value}", matching), type: BadArgument)
422
+ return BoolValue.true?(matching.first) if allowed_values.eql?(BoolValue::ALL)
423
+ matching.first
424
+ end
425
+
426
+ # Find a key in a list by exact match or unique prefix match
427
+ # @return [Object, nil] the matching key, or nil if none or ambiguous
428
+ def match_prefix(short_value, allowed_values)
429
+ return short_value if allowed_values.include?(short_value)
430
+ matches = allowed_values.select { |k| k.to_s.start_with?(short_value.to_s) }
431
+ matches.length == 1 ? matches.first : nil
432
+ end
433
+
434
+ # Generates error message with list of allowed values
435
+ # @param error_msg [String] Error message
436
+ # @param accept_list [Array<Symbol>] List of allowed values
437
+ # @param aliases [Hash{Symbol=>Symbol}, nil] alias→id map; used to annotate entries with their aliases
438
+ def multi_choice_assert_msg(error_msg, accept_list, aliases: nil)
439
+ # Build reverse map: id → [alias, ...] for annotation
440
+ reverse = aliases&.each_with_object({}) do |(ali, id), h|
441
+ (h[id] ||= []) << ali
442
+ end
443
+ lines = accept_list.map do |choice|
444
+ suffix = reverse&.key?(choice) ? " (alias: #{reverse[choice].join(', ')})" : ''
445
+ "- #{choice}#{suffix}"
446
+ end
447
+ [error_msg, 'Use:', *lines.sort].join("\n")
448
+ end
449
+
450
+ # Change option name with dash to name with underscore
451
+ # @param name [String] option name with dash separators
452
+ # @return [String] option name with underscore separators
453
+ def option_line_to_name(name)
454
+ name.gsub(Option::NAME_SEP_LINE, Option::NAME_SEP_SYMBOL)
455
+ end
456
+
457
+ # Convert option symbol to CLI line flag format
458
+ # @param name [Symbol, String] option name
459
+ # @return [String] option flag (e.g. "--option-name")
460
+ def option_name_to_line(name)
461
+ "#{Option::PREFIX}#{name.to_s.gsub(Option::NAME_SEP_SYMBOL, Option::NAME_SEP_LINE)}"
462
+ end
463
+
464
+ # Parse percent-selector string into field name and value (extended value is parsed in value)
465
+ # @param identifier [String] identifier to parse
466
+ # @return [Hash{Symbol => String}, nil] `{field:,value:}` if identifier is a percent selector, else `nil`
467
+ def percent_selector(identifier)
468
+ Aspera.assert_type(identifier, String)
469
+ if (m = identifier.match(REGEX_LOOKUP_ID_BY_FIELD))
470
+ return {field: m[1], value: ExtendedValue.instance.evaluate(m[2], context: "percent selector: #{m[1]}")}
471
+ end
472
+ nil
473
+ end
474
+
475
+ # Using dotted hash notation, convert value to bool, int, float or extended value
476
+ # @param value [String] The value to convert to appropriate type
477
+ # @return [Boolean, Integer, Float, String, Array, Hash] the converted value
478
+ def smart_convert(value)
479
+ case value
480
+ when 'true' then true
481
+ when 'false' then false
482
+ else
483
+ Integer(value, exception: false) ||
484
+ Float(value, exception: false) ||
485
+ ExtendedValue.instance.evaluate(value, context: 'dotted expression')
486
+ end
487
+ end
488
+ end
489
+
490
+ attr_accessor :ask_missing_mandatory, :ask_missing_optional, :help_requested
491
+ attr_writer :fail_on_missing_mandatory
492
+
493
+ # @param program_name [String] Name of the program
494
+ # @param argv [Array<String>, nil] Command line arguments to parse
495
+ def initialize(program_name, argv = nil)
496
+ # Option descriptions: maps option symbol to its OptionValue descriptor
497
+ # @type [Hash{Symbol => OptionValue}]
498
+ @declared_options = {}
499
+ # do we ask missing options and arguments to user ?
500
+ @ask_missing_mandatory = false # STDIN.isatty
501
+ # ask optional options if not provided and in interactive
502
+ @ask_missing_optional = false
503
+ # get_option fails if a mandatory parameter is asked
504
+ @fail_on_missing_mandatory = true
505
+ # set to true when --help / -h is parsed
506
+ @help_requested = false
507
+ # options explicitly reset to nil from CLI (e.g. --opt=@none:); preset injection skips these
508
+ @explicitly_cleared = {}
509
+ # options can also be provided by env vars : --param-name -> ASCLI_PARAM_NAME
510
+ @option_pairs_batch = {}
511
+ @option_pairs_env = {}
512
+ # Short option char -> option symbol, e.g. {'h' => :help, 'v' => :version}
513
+ @short_options = {}
514
+ # Current help section group name, set by #group
515
+ @current_group = 'global'
516
+ env_prefix = program_name.upcase + Option::NAME_SEP_SYMBOL
517
+ ENV.each do |k, v|
518
+ @option_pairs_env[k.delete_prefix(env_prefix).downcase.to_sym] = v if k.start_with?(env_prefix)
519
+ end
520
+ Log.dump(:env, @option_pairs_env)
521
+ # Ordered list of all CLI tokens after `--` splitting.
522
+ # Single source of truth for CLI parsing — pending_arguments and pending_options are derived views.
523
+ # @type [Array<Option, Argument>]
524
+ @argv_tokens = []
525
+ # Frozen snapshot of @argv_tokens used by unprocessed_options_with_value.
526
+ @initial_argv_tokens = [].freeze
527
+ # Number of original positional args before the option currently being parsed (nil = positional context).
528
+ @current_option_args_offset = nil
529
+ # Current index in @argv_tokens during parse_options! loop (used by shift_next_argument_token).
530
+ @current_parse_idx = nil
531
+ return if argv.nil?
532
+ # true until `--` is found (stop options)
533
+ process_options = true
534
+ argv.each do |value|
535
+ if process_options && value.start_with?('-')
536
+ Log.log.trace1 { "opt: #{value}" }
537
+ if value.eql?(OPTIONS_STOP)
538
+ process_options = false
539
+ else
540
+ @argv_tokens.push(value.start_with?(Option::PREFIX) ? Option.from_long(value) : Option.from_short(value))
541
+ end
542
+ else
543
+ Log.log.trace1 { "arg: #{value}" }
544
+ @argv_tokens.push(Argument.new(value))
545
+ end
546
+ end
547
+ @initial_argv_tokens = @argv_tokens.dup.freeze
548
+ Log.log.trace1 { "add_cmd_line_options:arguments=#{pending_arguments},options=#{pending_options}".red }
549
+ declare(:interactive, description: 'Use interactive input of missing params', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_mandatory})
550
+ declare(:ask_options, description: 'Ask even optional options', allowed: Type::BOOLEAN, handler: {o: self, m: :ask_missing_optional})
551
+ # do not parse options yet, let's wait for option `-h` to be overridden
552
+ end
553
+
554
+ # Add a type to the message if not special types
555
+ # @param types [Array<Class>] types to add
556
+ # @return [String] Types if relevant
557
+ def add_types_info(types)
558
+ return '' if !types || types.empty? || types.eql?(Type::ENUM) || types.eql?(Type::BOOLEAN) || types.eql?(Type::STRING)
559
+ " (#{types.map(&:name).join(', ')})"
560
+ end
561
+
562
+ # Declare an option
563
+ # @param option_symbol [Symbol] option name
564
+ # @param description [String, nil] description for help; if nil, derived from schema
565
+ # @param short [String] short option name
566
+ # @param allowed [Object] Allowed values, see `OptionValue`.
567
+ # When `schema:` is provided:
568
+ # - Omit `allowed:` when the schema has a single type (`type: object/array`): it is inferred automatically.
569
+ # - Omit `allowed:` when the schema uses `oneOf`/`anyOf` and all branches are `object`: `Hash` is inferred.
570
+ # - Use `allowed: [Hash, String]` when the option additionally accepts a plain String shorthand;
571
+ # the schema then documents the Hash form and `=help` still shows it.
572
+ # @param default [Object] default value
573
+ # @param handler [Hash] handler for option value: keys: :o(object) and :m(method)
574
+ # @param deprecation [String] deprecation
575
+ # @param schema [String] schema path documenting the Hash form of this option
576
+ # @param block [Proc] Block to execute when option is found
577
+ def declare(option_symbol, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil, &block)
578
+ Aspera.assert_type(option_symbol, Symbol)
579
+ Aspera.assert(!@declared_options.key?(option_symbol)) { "#{option_symbol} already declared" }
580
+ Aspera.assert_type(handler, Hash) if handler
581
+ Aspera.assert(handler.keys.sort.eql?(%i[m o]), 'handler must have keys :m and :o') if handler
582
+ option_attrs = @declared_options[option_symbol] = OptionValue.new(
583
+ option: option_symbol,
584
+ description: description,
585
+ allowed: allowed,
586
+ handler: handler,
587
+ deprecation: deprecation,
588
+ schema: schema
589
+ )
590
+ option_attrs.group = @current_group
591
+ description = option_attrs.description
592
+ Aspera.assert(!description.nil?) { "#{option_symbol}: no description and no schema to derive one from" }
593
+ Aspera.assert(description[-1] != '.') { "#{option_symbol} ends with dot" }
594
+ Aspera.assert(description[0] == description[0].upcase) { "#{option_symbol} description does not start with an uppercase" }
595
+ Aspera.assert(!['hash', 'extended value'].any? { |s| description.downcase.include?(s) }) { "#{option_symbol} shall use :allowed instead of hash/extended value in option description" }
596
+ set_option(option_symbol, default, where: 'default', warn_deprecation: false) unless default.nil?
597
+ case option_attrs.types
598
+ when Type::ENUM, Type::BOOLEAN
599
+ # This option value must be a symbol (or array of symbols)
600
+ set_option(option_symbol, BoolValue.true?(default), where: 'default', warn_deprecation: false) if option_attrs.values.eql?(BoolValue::ALL) && !default.nil?
601
+ when Type::NONE
602
+ Aspera.assert_type(block, Proc) { "missing execution block for #{option_symbol}" }
603
+ option_attrs.block = block
604
+ end
605
+ @short_options[short] = option_symbol unless short.nil?
606
+ Log.log.trace1 { "declare: #{option_symbol}, group: #{@current_group}, short: #{short}" }
607
+ end
608
+
609
+ # Set the current help section group name for subsequent declarations
610
+ # @param name [String] group name, shown as section header in help text
611
+ def group(name)
612
+ @current_group = name
613
+ end
614
+
615
+ # Rename all options currently tagged with @current_group to a new name,
616
+ # then update @current_group. Used by add_manual_header when a plugin
617
+ # declares its options before its group name is known (e.g. Plugins::Config).
618
+ # @param name [String] new group name
619
+ def rename_current_group(name)
620
+ @declared_options.each_value { |opt| opt.group = name if opt.group.eql?(@current_group) }
621
+ @current_group = name
622
+ end
623
+
624
+ # Low-level positional argument reader. Prefer +Base#resolve_argument+ from action methods.
625
+ # Direct calls from outside +Parser+ are legacy exceptions documented in ST12/ST13
626
+ # (mixins without DSL: sync_actions, ascp_actions; setup callbacks: aoc.rb).
627
+ # @api private
628
+ # @param descr [String] description for help
629
+ # @param mandatory [Boolean] `true`: raise error no more argument
630
+ # @param multiple [Boolean] `true`: return all remaining arguments (Array). String: until marker
631
+ # @param accept_list [Array<Symbol>, NilClass] list of allowed values
632
+ # @param validation [Class, Array, NilClass] Accepted value type(s) or list of Symbols
633
+ # @param aliases [Hash] map of aliases: key = alias, value = real value
634
+ # @param default [Object] default value
635
+ # @return [Object, Array, nil] one value, list or nil (if optional and no default)
636
+ def get_next_argument(descr, mandatory: true, multiple: false, accept_list: nil, validation: Type::STRING, aliases: nil, default: nil, schema: nil)
637
+ Aspera.assert_array_all(accept_list, Symbol) unless accept_list.nil?
638
+ Aspera.assert_hash_all(aliases, Symbol, Symbol) unless aliases.nil?
639
+ validation = Symbol unless accept_list.nil?
640
+ validation = [validation] unless validation.is_a?(Array) || validation.nil?
641
+ Aspera.assert_array_all(validation, Class) { 'validation' } unless validation.nil?
642
+ descr = "#{descr}#{add_types_info(validation)}"
643
+ result =
644
+ if !pending_arguments.empty?
645
+ values = extract_argument_tokens(multiple)
646
+ values = values.map { |v| ExtendedValue.instance.evaluate(v, context: "argument: #{descr}", allowed: validation) }
647
+ # If expecting list and only one arg of type array : it is the list
648
+ values = values.first if multiple && values.length.eql?(1) && values.first.is_a?(Array)
649
+ if accept_list
650
+ allowed_values = [].concat(accept_list)
651
+ allowed_values.concat(aliases.keys) unless aliases.nil?
652
+ values = values.map { |v| self.class.get_from_list(v, descr, allowed_values) }
653
+ end
654
+ multiple ? values : values.first
655
+ elsif !default.nil? then default
656
+ elsif mandatory then get_interactive(descr, multiple: multiple, accept_list: accept_list, aliases: aliases, schema: schema)
657
+ end
658
+ Log.log.trace1 { "#{descr}=#{result}" }
659
+ result = aliases[result] if aliases&.key?(result)
660
+ # if value comes from JSON/YAML, it may come as Integer
661
+ result = result.to_s if result.is_a?(Integer) && validation&.eql?(Type::STRING)
662
+ # Integer coercion: a String argument for an INTEGER option must be parseable
663
+ if result.is_a?(String) && validation&.eql?(Type::INTEGER)
664
+ int_result = Integer(result, exception: false)
665
+ raise Cli::BadArgument, "Invalid integer: #{result}" if int_result.nil?
666
+ result = int_result
667
+ end
668
+ if validation && (mandatory || !result.nil?)
669
+ value_list = multiple ? result : [result]
670
+ value_list.each { |value| validate_argument(value, validation: validation, descr: descr, schema: schema) }
671
+ end
672
+ result
673
+ end
674
+
675
+ # Resource identifier as positional parameter
676
+ #
677
+ # @param description [String] description of the identifier
678
+ # @param block [Proc] block to search for identifier based on attribute value
679
+ # @return [String, Array<String>] identifier or list of IDs (if `bulk` option is set)
680
+ # @yieldparam field [String] The field name from percent selector
681
+ # @yieldparam value [String] The value from percent selector
682
+ # @yieldreturn [String] Resolved identifier
683
+ def instance_identifier(description: 'identifier', &block)
684
+ res_id = get_next_argument(description, multiple: get_option(:bulk))
685
+ # Can be an Array
686
+ if res_id.is_a?(String) && (m = Parser.percent_selector(res_id))
687
+ Aspera.assert(block_given?, type: Cli::BadArgument) { "Percent syntax for #{description} not supported in this context" }
688
+ res_id = yield(m[:field], m[:value])
689
+ end
690
+ res_id
691
+ end
692
+
693
+ # Get next positional command argument from accepted list
694
+ # @param command_list [Array<Symbol>] accepted command names
695
+ # @param aliases [Hash, nil] command aliases
696
+ # @return [Symbol] selected command
697
+ def get_next_command(command_list, aliases: nil); get_next_argument('command', accept_list: command_list, aliases: aliases); end
698
+
699
+ # Check whether an option has already been declared in this manager
700
+ # @param option_symbol [Symbol] name of the option
701
+ # @return [Boolean]
702
+ def option_declared?(option_symbol)
703
+ @declared_options.key?(option_symbol)
704
+ end
705
+
706
+ # @return [Hash{Symbol => OptionValue}] all declared options (read-only view)
707
+ attr_reader :declared_options
708
+
709
+ # Get an option definition by name
710
+ # @param option_symbol [Symbol] name of the option
711
+ # @return [OptionValue] Option definition
712
+ # @raise [Cli::BadArgument] if option not found
713
+ def option_def(option_symbol)
714
+ Aspera.assert(@declared_options.key?(option_symbol), type: Cli::BadArgument) { "Unknown option: #{option_symbol}" }
715
+ @declared_options[option_symbol]
716
+ end
717
+
718
+ # Get an option value by name
719
+ # either return value or calls handler, can return nil
720
+ # ask interactively if requested/required
721
+ # @param option_symbol [Symbol] name of the option to retrieve
722
+ # @param mandatory [Boolean] if true, raise error if option not set
723
+ # @param schema [String, nil] contextual schema path override; when set, raises SchemaRequest
724
+ # if the option value is 'help' (used for --query whose schema depends on the current command)
725
+ def get_option(option_symbol, mandatory: false, schema: nil)
726
+ Aspera.assert_type(option_symbol, Symbol)
727
+ option_attrs = option_def(option_symbol)
728
+ result = option_attrs.value
729
+ # Contextual schema: raise SchemaRequest when value is 'help'
730
+ raise SchemaRequest.new(:option, option_symbol.to_s, schema) if schema && result.eql?(HELP)
731
+ # Do not fail for manual generation if option mandatory but not set
732
+ return :skip_missing_mandatory if result.nil? && mandatory && !@fail_on_missing_mandatory
733
+ if result.nil?
734
+ if !@ask_missing_mandatory
735
+ Aspera.assert(!mandatory, type: Cli::BadArgument) { "Missing mandatory option: #{option_symbol}" }
736
+ elsif @ask_missing_optional || mandatory
737
+ # ask_missing_mandatory
738
+ result = get_interactive(option_symbol.to_s, check_option: true, accept_list: option_attrs.values, schema: option_attrs.schema)
739
+ set_option(option_symbol, result, where: 'interactive')
740
+ end
741
+ end
742
+ result
743
+ end
744
+
745
+ # Set an option value by name, either store value or call handler
746
+ # String is given to extended value
747
+ # @param option_symbol [Symbol] option name
748
+ # @param value [String] Value to set
749
+ # @param where [String] Where the value comes from
750
+ def set_option(option_symbol, value, where: 'code override', warn_deprecation: true)
751
+ Aspera.assert_type(option_symbol, Symbol)
752
+ option = option_def(option_symbol)
753
+ # Raise immediately only when the option has a static schema: the schema is known at parse time.
754
+ # When schema is nil (e.g. --query), 'help' is stored as-is and SchemaRequest is raised later
755
+ # in get_option() with the contextual schema provided by the calling command.
756
+ raise SchemaRequest.new(:option, option.option, option.schema) if option.types&.include?(Hash) && value.eql?(HELP) && option.schema
757
+ option.assign_value(value, where: where, warn_deprecation: warn_deprecation)
758
+ end
759
+
760
+ # Set option to `nil`
761
+ # @param option_symbol [Symbol] option name
762
+ # @return [nil]
763
+ def clear_option(option_symbol)
764
+ Aspera.assert_type(option_symbol, Symbol)
765
+ option_def(option_symbol).clear
766
+ end
767
+
768
+ # Bind (or re-bind) a runtime handler to an already-declared option.
769
+ # Called from plugin initialize() for Category C handlers whose target object
770
+ # (e.g. @gen_options) is created after class-load time.
771
+ # @param option_symbol [Symbol] name of the already-declared option
772
+ # @param object [Object] the target object for get/set delegation
773
+ # @param method [Symbol] accessor method name on object
774
+ # @return [nil]
775
+ def set_handler(option_symbol, object:, method:)
776
+ Aspera.assert_type(option_symbol, Symbol)
777
+ option_def(option_symbol).bind_handler(o: object, m: method)
778
+ end
779
+
780
+ # Adds each of the keys of specified hash as an option
781
+ # @param preset_hash [Hash] Options to add
782
+ # @param where [String] Where the value comes from
783
+ # @param override [Boolean] Override if already present
784
+ def add_option_preset(preset_hash, where, override: true)
785
+ Aspera.assert_type(preset_hash, Hash)
786
+ Log.log.debug { "add_option_preset: #{preset_hash}, #{where}, #{override}" }
787
+ preset_hash.each do |k, v|
788
+ # Ignore comment/meta keys (e.g. _comment, _description)
789
+ next if k.to_s.start_with?(PresetManager::Key::META_PREFIX)
790
+ option_symbol = k.to_sym
791
+ # Never restore an option that was explicitly cleared from the CLI (e.g. --opt=@none:)
792
+ next if @explicitly_cleared.key?(option_symbol)
793
+ @option_pairs_batch[option_symbol] = v if override || !@option_pairs_batch.key?(option_symbol)
794
+ end
795
+ end
796
+
797
+ # Allows a plugin to add an argument as next argument to process
798
+ # @param argument [String] argument value to prepend
799
+ # @return [Array<Option, Argument>] updated tokens list
800
+ def unshift_next_argument(argument)
801
+ @argv_tokens.unshift(Argument.new(argument))
802
+ end
803
+
804
+ # Check if there are no pending positional arguments
805
+ # @return [Boolean] true if no pending positional arguments
806
+ def command_or_arg_empty?
807
+ pending_arguments.empty?
808
+ end
809
+
810
+ # Check for unprocessed options or arguments error messages
811
+ # @return [Array<String>] list of error messages for unprocessed tokens
812
+ def final_errors
813
+ result = []
814
+ result.push("unprocessed options: #{pending_options}") unless pending_options.empty?
815
+ result.push("unprocessed values: #{pending_arguments}") unless pending_arguments.empty?
816
+ result
817
+ end
818
+
819
+ # Get all original options on command line used to generate a config in config file
820
+ # @return [Hash] options as taken from config file and command line just before command execution
821
+ def unprocessed_options_with_value
822
+ result = {}
823
+ @initial_argv_tokens.each_with_index do |tok, idx|
824
+ next unless tok.is_a?(Option) && tok.short_char.nil?
825
+ # For space-separated form: value is the immediately following :argument token (if any)
826
+ value = tok.value || @initial_argv_tokens[idx + 1]&.then { |t| t.value if t.is_a?(Argument) }
827
+ # ignore options without value
828
+ next if value.nil?
829
+ name = tok.dot_path ? [tok.name, *tok.dot_path].join(DotContainer::SEPARATOR) : tok.name
830
+ Log.log.debug { "option #{name}=#{value}" }
831
+ path = [tok.name, *(tok.dot_path || [])]
832
+ DotContainer.dotted_to_container(path, Parser.smart_convert(value), result)
833
+ @argv_tokens.reject! { |t| t.is_a?(Option) && t.raw.eql?(tok.raw) }
834
+ end
835
+ result
836
+ end
837
+
838
+ # @param only_defined [Boolean] if true, only return options that were defined
839
+ # @return [Hash] options as taken from config file and command line just before command execution
840
+ def known_options(only_defined: false)
841
+ result = {}
842
+ @declared_options.each_key do |option_symbol|
843
+ v = get_option(option_symbol)
844
+ result[option_symbol] = v unless only_defined && v.nil?
845
+ rescue => e
846
+ result[option_symbol] = e.to_s
847
+ end
848
+ result
849
+ end
850
+
851
+ # Removes already known options from the list
852
+ def parse_options!
853
+ Log.log.trace1('parse_options!'.red)
854
+ # First options from conf file
855
+ @option_pairs_batch = consume_option_pairs(@option_pairs_batch, 'set')
856
+ # Then, env var (to override)
857
+ @option_pairs_env = consume_option_pairs(@option_pairs_env, 'env')
858
+ # Then, command line override.
859
+ # Iterate @argv_tokens in order so that --opt val and -s val can consume the next argument
860
+ # token directly, without any secondary index.
861
+ # Process one option at a time so that @current_option_args_offset can be set before each
862
+ # option is evaluated (used by `@:` extended value).
863
+ deferred_tokens = []
864
+ Log.log.trace1('Before parse')
865
+ Log.dump(:argv_tokens, @argv_tokens, level: :trace1)
866
+ # Iterate with an index so that Argument tokens stay in @argv_tokens (not shifted).
867
+ # Only Option tokens are removed; Arguments remain until consumed by extract_argument_tokens.
868
+ @current_parse_idx = 0
869
+ while @current_parse_idx < @argv_tokens.length
870
+ tok = @argv_tokens[@current_parse_idx]
871
+ if tok.is_a?(Argument)
872
+ @current_parse_idx += 1
873
+ next
874
+ end
875
+ # tok is an Option — remove it from @argv_tokens
876
+ @argv_tokens.delete_at(@current_parse_idx)
877
+ # @current_option_args_offset = number of positional Argument tokens that appear BEFORE
878
+ # this option in @argv_tokens (i.e. at indices 0...@current_parse_idx after the delete).
879
+ # Used by args_as_extended to skip those leading args when collecting @: values.
880
+ @current_option_args_offset = @argv_tokens[0...@current_parse_idx].count { |t| t.is_a?(Argument) }
881
+ if tok.short_char
882
+ # Short option: -X or -Xvalue
883
+ option_sym = @short_options[tok.short_char]
884
+ if option_sym
885
+ raw_value = tok.value
886
+ # No inline value and option expects a value: consume the next :argument token
887
+ raw_value = shift_next_argument_token \
888
+ if !tok.has_value && !@declared_options[option_sym].types.eql?(Type::NONE)
889
+ dispatch_option(option_sym, raw_value)
890
+ else
891
+ deferred_tokens.push(tok)
892
+ end
893
+ elsif tok.dot_path
894
+ # Dotted notation: --a.b.c=val or --a.b.c val (always takes priority over plain option lookup)
895
+ Log.log.trace1 { "Dotted option: #{tok.raw}".red }
896
+ if tok.has_value
897
+ raw_value = tok.value
898
+ value_from_next_token = false
899
+ else
900
+ raw_value = shift_next_argument_token
901
+ value_from_next_token = true
902
+ end
903
+ if @declared_options.key?(tok.name.to_sym)
904
+ set_option(tok.name.to_sym, DotContainer.dotted_to_container(tok.dot_path, Parser.smart_convert(raw_value), get_option(tok.name.to_sym)), where: 'dotted')
905
+ else
906
+ # Only re-inject if value was space-separated (consumed from next token); inline values stay in tok.value
907
+ @argv_tokens.unshift(Argument.new(raw_value)) if raw_value && value_from_next_token
908
+ deferred_tokens.push(tok)
909
+ end
910
+ elsif (resolved_sym = self.class.match_prefix(tok.name.to_sym, @declared_options.keys))
911
+ # Known long option (plain, no dot-path)
912
+ raw_value = tok.value
913
+ # No inline `=` and option expects a value: consume the next :argument token
914
+ raw_value = shift_next_argument_token \
915
+ if !tok.has_value && !@declared_options[resolved_sym].types.eql?(Type::NONE)
916
+ dispatch_option(resolved_sym, raw_value)
917
+ else
918
+ Log.log.trace1 { "Unknown long option: #{tok.raw}".red }
919
+ deferred_tokens.push(tok)
920
+ end
921
+ end
922
+ @current_option_args_offset = nil
923
+ @current_parse_idx = nil
924
+ Log.log.trace1('After parse')
925
+ Log.log.trace1 { "deferred: #{deferred_tokens}" }
926
+ # @argv_tokens now contains only: remaining Arguments + deferred Options (already in correct order).
927
+ # Re-insert deferred Options at their original positions by rebuilding from @initial_argv_tokens.
928
+ deferred_ids = deferred_tokens.map(&:object_id).to_set
929
+ remaining_arg_ids = @argv_tokens.grep(Argument).map(&:object_id).to_set
930
+ injected = @argv_tokens.grep(Argument).reject { |t| @initial_argv_tokens.include?(t) }
931
+ @argv_tokens = @initial_argv_tokens.select do |t|
932
+ (t.is_a?(Option) && deferred_ids.include?(t.object_id)) ||
933
+ (t.is_a?(Argument) && remaining_arg_ids.include?(t.object_id))
934
+ end
935
+ # Prepend arguments injected at runtime (e.g. via unshift_next_argument) not in @initial_argv_tokens.
936
+ @argv_tokens.unshift(*injected)
937
+ end
938
+
939
+ # Extract raw string tokens from @argv_tokens (Argument objects) according to `multiple`.
940
+ # Consumed Argument tokens are removed from @argv_tokens.
941
+ # @param multiple [false, true, String] consumption mode:
942
+ # false — consume exactly one token
943
+ # true — consume all remaining tokens
944
+ # String — consume up to (but not including) the marker token, or all if absent
945
+ # @return [Array<String>] consumed raw token strings
946
+ def extract_argument_tokens(multiple)
947
+ arg_tokens = @argv_tokens.grep(Argument)
948
+ case multiple
949
+ when false
950
+ tok = arg_tokens.first
951
+ @argv_tokens.delete(tok)
952
+ [tok.value]
953
+ when true
954
+ arg_tokens.each { |t| @argv_tokens.delete(t) }
955
+ arg_tokens.map(&:value)
956
+ when String
957
+ idx = arg_tokens.index { |t| t.value.eql?(multiple) }
958
+ consumed = idx ? arg_tokens[0, idx] : arg_tokens
959
+ marker = idx ? arg_tokens[idx] : nil
960
+ (consumed + [marker].compact).each { |t| @argv_tokens.delete(t) }
961
+ consumed.map(&:value)
962
+ else Aspera.error_unexpected_value(multiple) { 'multiple' }
963
+ end
964
+ end
965
+
966
+ # Validate and coerce a single argument value.
967
+ # Raises SchemaRequest when the value is 'help' and validation includes Hash.
968
+ # Raises BadArgument when the value is an Integer on a STRING validation (coerced upstream).
969
+ # Raises BadArgument when the value's type is not in the validation list.
970
+ # @param value [Object] the value to validate
971
+ # @param validation [Array<Class>] accepted types
972
+ # @param descr [String] argument description (for error messages)
973
+ # @param schema [String, nil] schema path for SchemaRequest
974
+ def validate_argument(value, validation:, descr:, schema:)
975
+ raise SchemaRequest.new(:argument, descr, schema) if validation.include?(Hash) && value.eql?(HELP)
976
+ raise Cli::BadArgument,
977
+ "Argument #{descr} is a #{value.class} but must be #{'one of: ' if validation.length > 1}#{validation.map(&:name).join(', ')}" \
978
+ unless validation.any? { |t| value.is_a?(t) }
979
+ end
980
+
981
+ # Prompt user for console input
982
+ # @param prompt [String] prompt string to display
983
+ # @param sensitive [Boolean] whether to hide typed input
984
+ # @return [String] user input stripped of trailing newline
985
+ def prompt_user_input(prompt, sensitive: false)
986
+ return $stdin.getpass("#{prompt}> ") if sensitive
987
+ print("#{prompt}> ")
988
+ line = $stdin.gets
989
+ Aspera.assert_type(line, String) { 'Unexpected end of standard input' }
990
+ line.chomp
991
+ end
992
+
993
+ # prompt user for input in a list of symbols
994
+ # @param prompt [String] prompt to display
995
+ # @param sym_list [Array] list of symbols to select from
996
+ # @return [Symbol] selected symbol
997
+ def prompt_user_input_in_list(prompt, sym_list)
998
+ loop do
999
+ input = prompt_user_input(prompt).to_sym
1000
+ if sym_list.any? { |a| a.eql?(input) }
1001
+ return input
1002
+ else
1003
+ $stderr.puts("No such #{prompt}: #{input}, select one of: #{sym_list.join(', ')}") # rubocop:disable Style/StderrPuts
1004
+ end
1005
+ end
1006
+ end
1007
+
1008
+ # Prompt user for input in a list of symbols
1009
+ # @param descr [String] description for help
1010
+ # @param check_option [Boolean] Check attributes of option with name=descr
1011
+ # @param multiple [Boolean, String] `true` if multiple values expected
1012
+ # @param accept_list [Array<Symbol>,NilClass] List of expected values
1013
+ # @return [String] user input
1014
+ def get_interactive(descr, check_option: false, multiple: false, accept_list: nil, aliases: nil, schema: nil)
1015
+ option_attrs = @declared_options[descr.to_sym]
1016
+ what = option_attrs ? 'option' : 'argument'
1017
+ default_prompt = "#{what}: #{descr}"
1018
+ if !@ask_missing_mandatory
1019
+ message = "Missing #{default_prompt}"
1020
+ message = self.class.multi_choice_assert_msg(message, accept_list, aliases: aliases) if accept_list
1021
+ message += "\n#{TerminalFormatter::HINT}Give `#{HELP}` as argument to retrieve the schema of the missing argument." if schema
1022
+ raise Cli::MissingArgument, message
1023
+ end
1024
+ # ask interactively
1025
+ result = []
1026
+ puts(' (one per line, end with empty line)') if multiple
1027
+ loop do
1028
+ prompt = default_prompt
1029
+ prompt = "#{accept_list.join(' ')}\n#{default_prompt}" if accept_list
1030
+ entry = prompt_user_input(prompt, sensitive: option_attrs&.sensitive)
1031
+ break if entry.empty? && multiple
1032
+ entry = ExtendedValue.instance.evaluate(entry, context: 'interactive input')
1033
+ entry = self.class.get_from_list(entry, descr, accept_list) if accept_list
1034
+ return entry unless multiple
1035
+ result.push(entry)
1036
+ end
1037
+ result
1038
+ end
1039
+
1040
+ # Read remaining args and build an `Array` or `Hash`
1041
+ # @param value [String] Argument to `@:` extended value
1042
+ # @return [Hash, Array] Object representing dot-path values
1043
+ def args_as_extended(end_marker)
1044
+ # This extended value does not take args (`@:`)
1045
+ # ExtendedValue.assert_no_value(end_marker, :p)
1046
+ end_marker = SpecialValues::EOA if end_marker.empty?
1047
+ # When called from an option value, skip positional args that appear before the option in argv.
1048
+ # @current_option_args_offset is set by parse_options! to the number of args in
1049
+ # @unprocessed_cmd_line_arguments that preceded this option; nil when called from a positional context.
1050
+ skip_count = @current_option_args_offset || 0
1051
+ # Skip leading positional args that precede this option in original argv order.
1052
+ # Grab the first `skip_count` Argument tokens and temporarily remove them.
1053
+ arg_tokens = @argv_tokens.grep(Argument)
1054
+ skipped_tokens = skip_count.positive? ? arg_tokens.first(skip_count) : []
1055
+ skipped_tokens.each { |t| @argv_tokens.delete(t) }
1056
+ Log.log.trace1 { "args_as_extended: skipping #{skipped_tokens.length} args before option: #{skipped_tokens.map(&:value)}" } unless skipped_tokens.empty?
1057
+ result = nil
1058
+ get_next_argument('args', multiple: end_marker).each do |argument|
1059
+ Aspera.assert(argument.include?(Option::VALUE_SEP)) { "Positional argument: #{argument} does not include #{Option::VALUE_SEP}" }
1060
+ path, value = argument.split(Option::VALUE_SEP, 2)
1061
+ result = DotContainer.dotted_to_container(path.split(DotContainer::SEPARATOR), Parser.smart_convert(value), result)
1062
+ end
1063
+ # Restore skipped tokens so they remain available for command dispatching
1064
+ skipped_tokens.reverse_each { |t| @argv_tokens.unshift(t) }
1065
+ result
1066
+ end
1067
+
1068
+ # Generate help text for all declared options, grouped by section.
1069
+ # @param banner [String, nil] Optional banner text to prepend
1070
+ # @return [String] Formatted help text
1071
+ def help_text(banner: nil)
1072
+ rows = []
1073
+ current_group = nil
1074
+ @declared_options.each do |sym, opt|
1075
+ if opt.group != current_group
1076
+ current_group = opt.group
1077
+ rows << [{value: "OPTIONS: #{current_group}", colspan: 2}]
1078
+ end
1079
+ short_char = @short_options.key(sym)
1080
+ short_part = short_char ? "-#{short_char}, " : ' '
1081
+ flag = "#{short_part}#{symbol_to_option(sym, option_display_value(opt))}"
1082
+ desc = opt.deprecation ? "#{opt.description} (deprecated: #{opt.deprecation})" : opt.description
1083
+ rows << [flag, desc]
1084
+ end
1085
+ table = Terminal::Table.new(rows: rows, style: {border: HELP_BORDER, padding_left: 0, padding_right: 2})
1086
+ banner.nil? ? table.to_s : "#{banner}\n#{table}"
1087
+ end
1088
+
1089
+ # ======================================================
1090
+ private
1091
+
1092
+ # AsciiBorder with all visible characters removed - used by help_text
1093
+ HELP_BORDER = Terminal::Table::AsciiBorder.new.tap do |b|
1094
+ b.top = false
1095
+ b.bottom = false
1096
+ b.left = false
1097
+ b.right = false
1098
+ b.remove_verticals
1099
+ b.remove_horizontals
1100
+ end.freeze
1101
+
1102
+ # @param opt [OptionValue] option descriptor
1103
+ # @return [String, nil] placeholder shown in flag column: 'ENUM', 'HASH', 'INT', 'LIST', 'VALUE', or nil for flag switches
1104
+ def option_display_value(opt)
1105
+ case opt.types
1106
+ when Type::NONE then nil
1107
+ when Type::BOOLEAN then 'yes|no'
1108
+ when Type::INTEGER then 'INT'
1109
+ when Type::ENUM
1110
+ opt.values&.any? && opt.values.length <= 4 ? opt.values.join('|') : 'ENUM'
1111
+ else
1112
+ if opt.types&.include?(Hash) || !opt.schema.nil?
1113
+ 'HASH'
1114
+ elsif opt.types&.include?(Array)
1115
+ 'LIST'
1116
+ else
1117
+ 'VALUE'
1118
+ end
1119
+ end
1120
+ end
1121
+
1122
+ # Dispatch a parsed CLI option to its handler.
1123
+ # @param sym [Symbol] option symbol
1124
+ # @param raw_value [String, nil] raw string value from command line, or nil for flag switches
1125
+ def dispatch_option(sym, raw_value)
1126
+ opt = @declared_options[sym]
1127
+ if opt.types.eql?(Type::NONE)
1128
+ opt.block.call
1129
+ else
1130
+ set_option(sym, raw_value, where: SOURCE_USER)
1131
+ # Track options explicitly cleared from CLI (e.g. --opt=@none:) so that
1132
+ # subsequent preset injection does not silently restore the value.
1133
+ # @none: always evaluates to nil; detect it by checking the raw token directly
1134
+ # to avoid re-evaluating side-effectful extended values (e.g. @: consumes positional args).
1135
+ cleared = get_option(sym).nil? || raw_value.eql?('@none:')
1136
+ @explicitly_cleared[sym] = true if cleared
1137
+ end
1138
+ end
1139
+
1140
+ # Generate command line option string from option symbol
1141
+ # @param symbol [Symbol] option name
1142
+ # @param opt_val [String, nil] optional value placeholder
1143
+ # @return [String] formatted option string (e.g. "--option=VALUE")
1144
+ def symbol_to_option(symbol, opt_val = nil)
1145
+ result = self.class.option_name_to_line(symbol)
1146
+ opt_val.nil? ? result : "#{result}#{Option::VALUE_SEP}#{opt_val}"
1147
+ end
1148
+
1149
+ # TODO: use formatter
1150
+ # Highlight current value in list
1151
+ # @param list [Array<Symbol>] List of possible values
1152
+ # @param current [Symbol] Current value
1153
+ # @return [String] comma separated sorted list of values, with the current value highlighted
1154
+ def highlight_current_in_list(list, current)
1155
+ list.sort.map do |i|
1156
+ if i.eql?(current)
1157
+ $stdout.isatty ? i.to_s.red.bold : "[#{i}]"
1158
+ else
1159
+ i
1160
+ end
1161
+ end.join(', ')
1162
+ end
1163
+
1164
+ # Try to evaluate options set in batch
1165
+ # @param unprocessed_options [Array] list of options to apply (key_sym,value)
1166
+ # @param where [String] where the options come from
1167
+ # Apply all known options from the given pairs hash and return the remaining (unknown) pairs.
1168
+ # Pure: does not mutate the argument; the caller is responsible for storing the returned value.
1169
+ # @param option_pairs [Hash{Symbol => Object}] candidate key/value pairs
1170
+ # @param where [String] label used in log messages and error context
1171
+ # @return [Hash{Symbol => Object}] pairs whose keys were not yet declared (deferred to next round)
1172
+ def consume_option_pairs(option_pairs, where)
1173
+ Log.log.trace1 { "consume_option_pairs: #{where}" }
1174
+ remaining = {}
1175
+ option_pairs.each do |k, v|
1176
+ if @declared_options.key?(k)
1177
+ set_option(k, v, where: where)
1178
+ else
1179
+ Log.log.trace1 { "unprocessed: #{k}: #{v}" }
1180
+ remaining[k] = v
1181
+ end
1182
+ end
1183
+ remaining
1184
+ end
1185
+
1186
+ # Consume the Argument token immediately following the current option in @argv_tokens.
1187
+ # After delete_at(@current_parse_idx), the next token is at @argv_tokens[@current_parse_idx].
1188
+ # @return [String, nil] the consumed argument value, or nil if the next token is not an Argument
1189
+ def shift_next_argument_token
1190
+ pos = @current_parse_idx || 0
1191
+ return unless @argv_tokens[pos]&.is_a?(Argument)
1192
+
1193
+ @argv_tokens.delete_at(pos).value
1194
+ end
1195
+
1196
+ # @return [Array<String>] values of all pending positional Argument tokens
1197
+ def pending_arguments
1198
+ @argv_tokens.grep(Argument).map(&:value)
1199
+ end
1200
+
1201
+ # @return [Array<String>] raw strings of all pending Option tokens
1202
+ def pending_options
1203
+ @argv_tokens.grep(Option).map(&:raw)
1204
+ end
1205
+
1206
+ # when this is alone, this stops option processing (same characters as Option::PREFIX but distinct semantics)
1207
+ OPTIONS_STOP = '--'
1208
+ SOURCE_USER = 'cmdline' # cspell:disable-line
1209
+ # Percent selector: select by this field for this value
1210
+ REGEX_LOOKUP_ID_BY_FIELD = /^%([^:]+):(.*)$/
1211
+ # Ask for schema of Extended value
1212
+ HELP = 'help'
1213
+
1214
+ private_constant :OPTIONS_STOP, :SOURCE_USER, :REGEX_LOOKUP_ID_BY_FIELD, :HELP_BORDER
1215
+ end
1216
+ end
1217
+ end