aspera-cli 4.26.1 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (145) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +300 -22
  4. data/CONTRIBUTING.md +100 -12
  5. data/bin/ascli +5 -5
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +27 -25
  12. data/lib/aspera/agent/factory.rb +4 -0
  13. data/lib/aspera/agent/httpgw.rb +2 -2
  14. data/lib/aspera/agent/node.rb +34 -2
  15. data/lib/aspera/agent/transferd.rb +39 -3
  16. data/lib/aspera/api/alee.rb +1 -1
  17. data/lib/aspera/api/aoc.rb +56 -51
  18. data/lib/aspera/api/cos_node.rb +2 -2
  19. data/lib/aspera/api/faspex.rb +22 -20
  20. data/lib/aspera/api/httpgw.rb +4 -4
  21. data/lib/aspera/api/node.rb +33 -30
  22. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  23. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  24. data/lib/aspera/ascmd.rb +25 -23
  25. data/lib/aspera/ascp/installation.rb +43 -40
  26. data/lib/aspera/ascp/management.rb +2 -3
  27. data/lib/aspera/cli/ascp_actions.rb +155 -0
  28. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  29. data/lib/aspera/cli/bootstrapper.rb +197 -0
  30. data/lib/aspera/cli/command_registry.rb +145 -0
  31. data/lib/aspera/cli/command_spec.rb +138 -0
  32. data/lib/aspera/cli/context.rb +71 -0
  33. data/lib/aspera/cli/error.rb +16 -0
  34. data/lib/aspera/cli/extended_value.rb +5 -3
  35. data/lib/aspera/cli/formatter.rb +122 -209
  36. data/lib/aspera/cli/gem_checker.rb +65 -0
  37. data/lib/aspera/cli/hints.rb +2 -2
  38. data/lib/aspera/cli/http.rb +218 -0
  39. data/lib/aspera/cli/info.rb +2 -0
  40. data/lib/aspera/cli/mailer.rb +97 -0
  41. data/lib/aspera/cli/mcp_tool.rb +198 -0
  42. data/lib/aspera/cli/options.schema.yaml +364 -7
  43. data/lib/aspera/cli/{manager.rb → parser.rb} +337 -144
  44. data/lib/aspera/cli/plugins/alee.rb +20 -22
  45. data/lib/aspera/cli/plugins/aoc.rb +1173 -849
  46. data/lib/aspera/cli/plugins/ats.rb +200 -161
  47. data/lib/aspera/cli/plugins/base.rb +470 -107
  48. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  49. data/lib/aspera/cli/plugins/config.rb +434 -1032
  50. data/lib/aspera/cli/plugins/console.rb +106 -64
  51. data/lib/aspera/cli/plugins/cos.rb +44 -32
  52. data/lib/aspera/cli/plugins/factory.rb +7 -4
  53. data/lib/aspera/cli/plugins/faspex.rb +296 -259
  54. data/lib/aspera/cli/plugins/faspex5.rb +592 -344
  55. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  56. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  57. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  58. data/lib/aspera/cli/plugins/node.rb +1001 -797
  59. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  60. data/lib/aspera/cli/plugins/orchestrator.rb +111 -139
  61. data/lib/aspera/cli/plugins/preview.rb +183 -142
  62. data/lib/aspera/cli/plugins/server.rb +125 -80
  63. data/lib/aspera/cli/plugins/shares.rb +301 -107
  64. data/lib/aspera/cli/preset_actions.rb +139 -0
  65. data/lib/aspera/cli/preset_manager.rb +236 -0
  66. data/lib/aspera/cli/result.rb +360 -0
  67. data/lib/aspera/cli/runner.rb +473 -0
  68. data/lib/aspera/cli/secret_finder.rb +40 -0
  69. data/lib/aspera/cli/special_values.rb +1 -0
  70. data/lib/aspera/cli/sync_actions.rb +84 -56
  71. data/lib/aspera/cli/terminal_formatter.rb +65 -0
  72. data/lib/aspera/cli/transfer_actions.rb +83 -0
  73. data/lib/aspera/cli/transfer_agent.rb +116 -51
  74. data/lib/aspera/cli/transfer_progress.rb +9 -9
  75. data/lib/aspera/cli/vault_manager.rb +57 -0
  76. data/lib/aspera/cli/version.rb +1 -1
  77. data/lib/aspera/cli/wizard.rb +26 -26
  78. data/lib/aspera/command_line_builder.rb +23 -23
  79. data/lib/aspera/data_repository.rb +6 -1
  80. data/lib/aspera/dot_container.rb +5 -5
  81. data/lib/aspera/environment.rb +39 -13
  82. data/lib/aspera/exec_spec.rb +13 -0
  83. data/lib/aspera/faspex_gw.rb +2 -2
  84. data/lib/aspera/faspex_postproc.rb +4 -3
  85. data/lib/aspera/graphql.rb +35 -0
  86. data/lib/aspera/hash_ext.rb +6 -0
  87. data/lib/aspera/json_rpc/client.rb +62 -0
  88. data/lib/aspera/json_rpc/version.rb +7 -0
  89. data/lib/aspera/keychain/base.rb +1 -1
  90. data/lib/aspera/keychain/encrypted_hash.rb +2 -2
  91. data/lib/aspera/keychain/factory.rb +4 -4
  92. data/lib/aspera/keychain/macos_security.rb +4 -4
  93. data/lib/aspera/link_header.rb +82 -0
  94. data/lib/aspera/log.rb +23 -5
  95. data/lib/aspera/markdown.rb +85 -2
  96. data/lib/aspera/nagios.rb +2 -2
  97. data/lib/aspera/node_simulator.rb +3 -1
  98. data/lib/aspera/oauth/base.rb +10 -10
  99. data/lib/aspera/oauth/boot.rb +43 -0
  100. data/lib/aspera/oauth/factory.rb +41 -15
  101. data/lib/aspera/oauth/jwt.rb +4 -4
  102. data/lib/aspera/oauth/web.rb +4 -4
  103. data/lib/aspera/oauth.rb +1 -0
  104. data/lib/aspera/persistency_action_once.rb +10 -10
  105. data/lib/aspera/persistency_folder.rb +10 -3
  106. data/lib/aspera/preview/file_types.rb +7 -3
  107. data/lib/aspera/preview/generator.rb +3 -3
  108. data/lib/aspera/preview/utils.rb +1 -1
  109. data/lib/aspera/products/connect.rb +4 -1
  110. data/lib/aspera/products/other.rb +1 -1
  111. data/lib/aspera/products/transferd.rb +3 -3
  112. data/lib/aspera/proxy_auto_config.rb +10 -9
  113. data/lib/aspera/rest.rb +18 -40
  114. data/lib/aspera/rest_error_analyzer.rb +7 -3
  115. data/lib/aspera/rest_errors_aspera.rb +0 -10
  116. data/lib/aspera/rest_list.rb +13 -6
  117. data/lib/aspera/schema/IBM Aspera Faspex API-5.0-enhanced.yaml +14 -4
  118. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +2730 -648
  119. data/lib/aspera/schema/async_tables.yaml +361 -0
  120. data/lib/aspera/schema/documentation.rb +57 -26
  121. data/lib/aspera/schema/reader.rb +60 -11
  122. data/lib/aspera/schema/registry.rb +50 -10
  123. data/lib/aspera/secret_hider.rb +11 -2
  124. data/lib/aspera/ssl.rb +4 -4
  125. data/lib/aspera/sync/conf.schema.yaml +2 -2
  126. data/lib/aspera/sync/database.rb +14 -10
  127. data/lib/aspera/sync/operations.rb +15 -18
  128. data/lib/aspera/temp_file_manager.rb +5 -1
  129. data/lib/aspera/timer_limiter.rb +1 -1
  130. data/lib/aspera/transfer/faux_file.rb +24 -11
  131. data/lib/aspera/transfer/parameters.rb +27 -25
  132. data/lib/aspera/transfer/result.rb +74 -0
  133. data/lib/aspera/transfer/resumer.rb +7 -7
  134. data/lib/aspera/transfer/spec.rb +18 -1
  135. data/lib/aspera/transfer/spec.schema.yaml +26 -8
  136. data/lib/aspera/transfer/uri.rb +1 -1
  137. data/lib/aspera/uri_reader.rb +2 -2
  138. data/lib/aspera/web_auth.rb +2 -2
  139. data/lib/aspera/web_server_simple.rb +8 -5
  140. data/lib/aspera/yaml.rb +2 -1
  141. data.tar.gz.sig +0 -0
  142. metadata +45 -16
  143. metadata.gz.sig +0 -0
  144. data/lib/aspera/cli/main.rb +0 -480
  145. data/lib/aspera/json_rpc.rb +0 -51
@@ -1,7 +1,11 @@
1
1
  # frozen_string_literal: true
2
2
 
3
3
  require 'aspera/cli/extended_value'
4
+ require 'aspera/cli/parser'
4
5
  require 'aspera/assert'
6
+ require 'aspera/cli/result'
7
+ require 'aspera/cli/command_registry'
8
+ require 'aspera/schema/registry'
5
9
 
6
10
  module Aspera
7
11
  module Cli
@@ -19,25 +23,168 @@ module Aspera
19
23
  ALL = (GLOBAL + INSTANCE).freeze
20
24
  end
21
25
  class << self
22
- def declare_options(options)
23
- options.declare(:query, 'Additional filter for for some commands (list/delete)', allowed: [Hash, Array, NilClass])
24
- options.declare(:bulk, 'Bulk operation (only some)', allowed: Allowed::TYPES_BOOLEAN, default: false)
25
- options.declare(:bfail, 'Bulk operation error handling', allowed: Allowed::TYPES_BOOLEAN, default: true)
26
+ # Per-class DSL registry (not inherited: each subclass gets its own instance).
27
+ # @return [CommandRegistry]
28
+ def command_registry
29
+ @command_registry ||= CommandRegistry.new
30
+ end
31
+
32
+ # DSL class method: register a command in this plugin's registry.
33
+ # Inherits parent from the enclosing commands_under block when parent: is omitted.
34
+ # @param id [Symbol]
35
+ # @param kwargs [Hash] forwarded to CommandSpec
36
+ def command(id, **kwargs)
37
+ kwargs[:parent] = @current_parent if kwargs[:parent].nil? && @current_parent
38
+ command_registry.register(CommandSpec.new(id: id, **kwargs))
39
+ end
40
+
41
+ # DSL class method: shorthand for a command whose sole action is Base#entity_execute.
42
+ # The api: value is a Symbol resolved at runtime: if it starts with '@' it is treated
43
+ # as an instance variable name; otherwise it is sent as a method call.
44
+ # description: defaults to "Manage <last segment of entity path>" when omitted.
45
+ # @param id [Symbol] Command identifier
46
+ # @param api [Symbol] Method name or :@ivar returning the REST API at runtime
47
+ # @param entity [String] API sub-path (e.g. 'admin/dropboxes')
48
+ # @param description [String, nil] User-facing help text; derived from entity when nil
49
+ # @param kwargs [Hash] Any other entity_execute params (display_fields:, command:, is_singleton:, etc.)
50
+ def entity_command(id, api:, entity:, description: nil, **kwargs)
51
+ description ||= "Manage #{entity.split('/').last}"
52
+ command(id, description: description, entity_execute: {api: api, entity: entity, **kwargs})
53
+ end
54
+
55
+ # DSL class method: define an instance method whose name is derived from a path array.
56
+ # Equivalent to: define_method(CommandSpec.action_method(path), &block)
57
+ # @param path [Array<Symbol>] command path segments, e.g. [:admin, :user, :list]
58
+ # @yieldparam [Hash] keyword context forwarded from dispatch
59
+ def define_action_method(path, &block)
60
+ define_method(CommandSpec.action_method(path), &block)
61
+ end
62
+
63
+ # DSL class method: scope block that sets a default parent for nested command() calls.
64
+ # Fully re-entrant: blocks may be nested for multi-level parent paths.
65
+ # @param parent [Symbol, Array<Symbol>] parent path applied to every command() inside
66
+ # @yieldreturn [void]
67
+ def commands_under(parent)
68
+ previous = @current_parent
69
+ @current_parent = parent
70
+ yield
71
+ ensure
72
+ @current_parent = previous
73
+ end
74
+
75
+ # DSL class method: declare an option in this plugin's registry.
76
+ # Metadata is stored as an OptionSpec at class-load time; the actual
77
+ # options.declare call happens in Base#initialize once the instance exists.
78
+ #
79
+ # Raises ArgumentError at class-load time if the same option name is already
80
+ # declared by any ancestor class, preventing silent shadowing.
81
+ #
82
+ # @param name [Symbol] Option name
83
+ # @param description [String, nil] User-facing description; if nil, derived from schema: title/description
84
+ # @param short [String, nil] Single-character short form (without leading '-')
85
+ # @param allowed [Object, nil] Allowed values (see OptionValue)
86
+ # @param default [Object, nil] Default value
87
+ # @param handler [Symbol, Hash, nil]
88
+ # - Symbol: resolved to {o: <plugin instance>, m: <symbol>} at runtime (Category B)
89
+ # - Hash: {o: <object>, m: <method>} used as-is (Category A: singletons / constants)
90
+ # - nil: option stores its value locally (no delegation)
91
+ # @param deprecation [String, nil] Deprecation message forwarded to options.declare
92
+ # @param schema [String, nil] Schema reference (e.g. "opts:components.schemas.Foo");
93
+ # when description: is nil, the schema title or first description line is used
94
+ def option(name, description: nil,
95
+ short: nil, allowed: nil, default: nil,
96
+ handler: nil, deprecation: nil, schema: nil)
97
+ ancestor_owner = ancestors.drop(1).find do |klass|
98
+ klass.is_a?(Class) && klass <= Base &&
99
+ klass.instance_variable_defined?(:@command_registry) &&
100
+ klass.command_registry.option_specs.key?(name)
101
+ end
102
+ raise ArgumentError, "#{self}: option :#{name} already declared in ancestor #{ancestor_owner}" if ancestor_owner
103
+ command_registry.register_option(
104
+ OptionSpec.new(
105
+ name: name,
106
+ description: description,
107
+ short: short,
108
+ allowed: allowed,
109
+ default: default,
110
+ handler: handler,
111
+ deprecation: deprecation,
112
+ schema: schema
113
+ )
114
+ )
115
+ end
116
+
117
+ # DSL class method: declare a setup method to run once before root dispatch.
118
+ # The method is called before any command is consumed, and its return value
119
+ # (a Hash) is merged into the initial ctx. This is useful when conditions
120
+ # on root commands depend on state built during setup (e.g. @connection_type).
121
+ # @param method_name [Symbol]
122
+ def root_setup(method_name)
123
+ @root_setup_method = method_name
124
+ end
125
+
126
+ # @return [Symbol, nil]
127
+ attr_reader :root_setup_method
128
+
129
+ # DSL class method: declare the human-readable application name shown in wizards.
130
+ # When called with an argument, sets the name. When called with no argument, returns it.
131
+ # Falls back to the last component of the class name if never set.
132
+ # @param name [String, nil]
133
+ # @return [String]
134
+ def application_name(name = nil)
135
+ @application_name = name unless name.nil?
136
+ @application_name || self.name.split('::').last
26
137
  end
27
138
  end
28
139
 
140
+ option :query, description: 'Additional filter for for some commands (list/delete)', allowed: [Hash, Array, NilClass]
141
+ option :bulk, description: 'Bulk operation (only some)', allowed: Allowed::TYPES_BOOLEAN, default: false
142
+ option :bfail, description: 'Bulk operation error handling', allowed: Allowed::TYPES_BOOLEAN, default: true
143
+
29
144
  def initialize(context:)
30
- # Check presence in descendant of mandatory method and constant
31
- Aspera.assert(respond_to?(:execute_action), type: InternalError){"Missing method 'execute_action' in #{self.class}"}
32
- Aspera.assert(self.class.const_defined?(:ACTIONS), type: InternalError){"Missing constant 'ACTIONS' in #{self.class}"}
145
+ Aspera.assert_type(context, Context){'context'}
146
+ Aspera.assert_type(context.man_header, TrueClass, FalseClass){'context.man_header'}
33
147
  @context = context
148
+ # Auto-declare all options registered via the DSL `option` class method.
149
+ # Walk the ancestor chain so that options declared on parent plugin classes
150
+ # (e.g. Oauth, BasicAuth) are also registered for sub-classes (e.g. Aoc).
151
+ # The options object is shared across all plugins in a run; skip options already
152
+ # declared by an earlier plugin (Base.option prevents duplicates within one hierarchy).
153
+ # Each OptionSpec is translated to an options.declare call, resolving the
154
+ # handler: shorthand:
155
+ # Symbol handler: {o: self, m: <symbol>} (Category B - plugin instance methods)
156
+ # Hash handler: used as-is (Category A - singletons / class constants)
157
+ self.class.ancestors.each do |klass|
158
+ next unless klass.is_a?(Class) && klass <= Base && klass.instance_variable_defined?(:@command_registry)
159
+ klass.command_registry.option_specs.each_value do |spec|
160
+ next if options.option_declared?(spec.name)
161
+ resolved_handler =
162
+ case spec.handler
163
+ when Symbol then {o: self, m: spec.handler}
164
+ when Hash then spec.handler
165
+ end
166
+ options.declare(
167
+ spec.name,
168
+ description: spec.description,
169
+ short: spec.short,
170
+ allowed: spec.allowed,
171
+ default: spec.default,
172
+ handler: resolved_handler,
173
+ deprecation: spec.deprecation,
174
+ schema: spec.schema
175
+ )
176
+ end
177
+ end
34
178
  add_manual_header if @context.man_header
35
179
  end
36
180
 
37
181
  # Global objects
38
182
  attr_reader :context
183
+ # Path reached in the command tree at the moment --help was intercepted.
184
+ # Nil until set by dispatch_from_registry.
185
+ attr_reader :help_path
39
186
 
40
- # @return [Aspera::Cli::Manager]
187
+ # @return [Aspera::Cli::Parser]
41
188
  def options; @context.options; end
42
189
  # @return [Aspera::Cli::TransferAgent]
43
190
  def transfer; @context.transfer; end
@@ -47,63 +194,274 @@ module Aspera
47
194
  def formatter; @context.formatter; end
48
195
  # @return [Aspera::PersistencyFolder]
49
196
  def persistency; @context.persistency; end
197
+ # @return [Aspera::Cli::PresetManager]
198
+ def presets; @context.presets; end
199
+ # @return [Aspera::Cli::Http]
200
+ def http_config; @context.http_config; end
201
+ # @return [Aspera::Cli::TransferProgress, nil]
202
+ def progress_bar; @context.progress_bar; end
50
203
 
51
- def add_manual_header(has_options = true)
52
- # Manual header for all plugins
53
- options.parser.separator('')
54
- options.parser.separator("COMMAND: #{self.class.name.split('::').last.downcase}")
55
- options.parser.separator("SUBCOMMANDS: #{self.class.const_get(:ACTIONS).map(&:to_s).sort.join(' ')}")
56
- options.parser.separator('OPTIONS:') if has_options
57
- end
58
-
59
- # For create and delete operations: execute one action or multiple if bulk is yes
60
- # @param command [Symbol] Operation: :create, :delete, ...
61
- # @param descr [String, nil] Description of the value
62
- # @param values [Class, Array, Symbol] Type, or list of values, or :identifier, result is given to the block in loop
63
- # @param id_result [String] Key in result Hash to use as identifier
64
- # @param fields [Symbol, Array] Fields to display
65
- # @param schema [Hash, nil] JSON schema for validation
66
- # @yieldparam param [Object] The parameter value to process
67
- # @yieldreturn [Hash, nil] Result hash for the operation (optional)
68
- # @return [Hash] Result suitable for CLI output
69
- def do_bulk_operation(command:, descr: nil, values: Hash, id_result: 'id', fields: :default, schema: nil, &block)
70
- Aspera.assert(block_given?){'missing block'}
71
- is_bulk = options.get_option(:bulk)
72
- case values
73
- when :identifier
74
- values = options.instance_identifier(description: descr)
75
- when Class
76
- values = value_create_modify(command: command, description: descr, type: values, bulk: is_bulk, schema: schema)
77
- end
78
- # If not bulk, there is a single value
79
- params = is_bulk ? values : [values]
80
- Log.log.warn('Empty list given for bulk operation') if params.empty?
81
- Log.dump(:bulk_operation, params)
82
- result_list = []
83
- params.each do |param|
84
- # Init for delete
85
- result = {id_result => param}
86
- begin
87
- # Execute custom code
88
- res = yield(param)
89
- # If block returns a hash, let's use this (create)
90
- result = res if res.is_a?(Hash)
91
- # TODO: remove when faspio gw api fixes this
92
- result = res.first if res.is_a?(Array) && res.first.is_a?(Hash)
93
- # Create -> created
94
- result['status'] = "#{command}#{'e' unless command.to_s.end_with?('e')}d".gsub(/yed$/, 'ied')
95
- rescue StandardError => e
96
- raise e if options.get_option(:bfail)
97
- result['status'] = e.to_s
204
+ def add_manual_header(_has_options = true)
205
+ options.rename_current_group(self.class.name.split('::').last.downcase)
206
+ end
207
+
208
+ # Entry point for all DSL-based plugins.
209
+ def execute_action
210
+ @help_path = nil
211
+ # Validate the registry once per class (memoised by the ivar check).
212
+ # Passes the plugin class so implicit handler methods can be verified.
213
+ unless self.class.instance_variable_defined?(:@registry_validated)
214
+ self.class.command_registry.validate!(plugin_class: self.class)
215
+ self.class.instance_variable_set(:@registry_validated, true)
216
+ end
217
+ # Run the root setup (if declared) before consuming any argument.
218
+ # This ensures condition methods on root commands can read instance variables
219
+ # populated by the setup (e.g. @connection_type in server.rb).
220
+ init_ctx = {}
221
+ if (rsm = self.class.root_setup_method)
222
+ init_ctx = send(rsm) || {}
223
+ end
224
+ dispatch_from_registry([], init_ctx)
225
+ end
226
+
227
+ # Two-phase dispatcher: run setup on the current node (Phase A), then either
228
+ # execute a leaf directly or consume the next argument and recurse (Phase B).
229
+ # @param current_path [Array<Symbol>] path of the node currently being dispatched
230
+ # @param ctx [Hash] accumulated context passed down from parent nodes
231
+ # @param skip_setup [Boolean] when true, skip Phase A (setup already done by caller)
232
+ # @return [Object] result suitable for CLI output
233
+ def dispatch_from_registry(current_path, ctx = {}, skip_setup: false)
234
+ registry = self.class.command_registry
235
+ spec = registry[current_path]
236
+ is_leaf = spec && registry.children_of(current_path).empty?
237
+
238
+ if options.help_requested || skip_setup
239
+ # help_requested on an intermediate node: drain positional args without validation
240
+ # so that dispatch_child can still consume the correct sub-command token
241
+ if !is_leaf && !skip_setup && spec&.arguments
242
+ spec.arguments.each do |arg_spec|
243
+ next if ctx.key?(arg_spec.name)
244
+ options.get_next_argument(arg_spec.name.to_s, mandatory: false)
245
+ end
98
246
  end
99
- result_list.push(result)
247
+ else
248
+ # Phase A - for intermediate nodes only: resolve all ArgumentSpec declared on this node
249
+ # before dispatching to children (leaf nodes resolve their arguments inside execute_leaf).
250
+ if !is_leaf
251
+ (spec&.arguments || []).each do |arg_spec|
252
+ next if ctx.key?(arg_spec.name)
253
+ if arg_spec.type.eql?(:identifier)
254
+ lookup_method = arg_spec.lookup
255
+ res_id = if lookup_method
256
+ options.instance_identifier(description: arg_spec.name.to_s){ |f, v| send(lookup_method, f, v, **ctx)}
257
+ else
258
+ options.instance_identifier(description: arg_spec.name.to_s)
259
+ end
260
+ ctx = ctx.merge(arg_spec.name => res_id)
261
+ else
262
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
263
+ end
264
+ end
265
+ end
266
+ ctx = ctx.merge(send(spec.setup, **ctx)) if spec&.setup
100
267
  end
101
- display_fields = [id_result, 'status']
102
- if is_bulk
103
- return Main.result_object_list(result_list, fields: display_fields)
268
+
269
+ # Phase B - leaf fast-path or child dispatch
270
+ if is_leaf
271
+ dispatch_leaf(current_path, spec, ctx)
104
272
  else
105
- display_fields = fields unless fields.eql?(:default)
106
- return Main.result_single_object(result_list.first, fields: display_fields)
273
+ dispatch_child(current_path, registry, ctx)
274
+ end
275
+ end
276
+
277
+ # Phase B, leaf branch: execute a spec that is already a leaf (no children).
278
+ # Intercepts --help before calling execute_leaf.
279
+ # @param current_path [Array<Symbol>]
280
+ # @param spec [CommandSpec]
281
+ # @param ctx [Hash]
282
+ # @return [Object]
283
+ def dispatch_leaf(current_path, spec, ctx)
284
+ if options.help_requested
285
+ @help_path = current_path
286
+ raise Cli::HelpRequest, self
287
+ end
288
+ execute_leaf(spec, ctx)
289
+ end
290
+
291
+ # Phase B, child branch: consume the next command argument, resolve the matching
292
+ # child spec, handle delegation / entity_execute shorthands, and recurse or execute.
293
+ # @param current_path [Array<Symbol>]
294
+ # @param registry [CommandRegistry]
295
+ # @param ctx [Hash]
296
+ # @return [Object]
297
+ def dispatch_child(current_path, registry, ctx)
298
+ children = registry.children_of(current_path)
299
+ available = children.reject{ |_, c| c.condition && !send(c.condition)}
300
+ aliases = children.values.each_with_object({}) do |c, h|
301
+ Array(c.aliases).each{ |a| h[a] = c.id} if c.aliases
302
+ end
303
+ command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
304
+ child = available[command]
305
+
306
+ # Intercept --help when no more positional args remain after consuming this command.
307
+ # (e.g. `aoc files -h` or `aoc files find -h`). When args remain, keep recursing.
308
+ if options.help_requested && options.command_or_arg_empty?
309
+ @help_path = current_path + [command]
310
+ raise Cli::HelpRequest, self
311
+ end
312
+
313
+ # Instance delegation: hand off to a different plugin object
314
+ if child.delegate_instance
315
+ target = send(child.delegate_instance)
316
+ return target.dispatch_from_registry(Array(child.delegates_to), {})
317
+ end
318
+ return dispatch_from_registry(Array(child.delegates_to), ctx) if child.delegates_to
319
+
320
+ # entity_execute shorthand
321
+ return run_entity_execute(child, ctx) if child.entity_execute
322
+
323
+ # Both intermediate and leaf: instance_arg + setup are handled by Phase A of the next call
324
+ dispatch_from_registry(current_path + [command], ctx)
325
+ end
326
+
327
+ # Resolve the action for a leaf CommandSpec.
328
+ # Returns spec.action (Symbol or Proc) if explicitly set; otherwise derives a Symbol
329
+ # from the full path as :action_<path_segment_1>_<path_segment_2>_...
330
+ # (e.g. [:access_key, :list] -> :action_access_key_list).
331
+ # @param spec [CommandSpec]
332
+ # @return [Symbol, Proc]
333
+ def action_for(spec)
334
+ spec.action || spec.action_method_name
335
+ end
336
+
337
+ # Invoke an action (Symbol method or Proc block) with the given positional
338
+ # arguments and keyword context.
339
+ # Procs are executed via instance_exec so they share the plugin's `self`.
340
+ # @param action [Symbol, Proc]
341
+ # @param args [Array] positional arguments
342
+ # @param ctx [Hash] keyword context
343
+ # @return [Object]
344
+ def invoke_action(action, args, ctx)
345
+ if action.is_a?(Proc)
346
+ instance_exec(*args, **ctx, &action)
347
+ else
348
+ send(action, *args, **ctx)
349
+ end
350
+ end
351
+
352
+ # Execute a leaf CommandSpec: resolve arguments (or skip for transfer_paths) and call action.
353
+ # instance_arg (if any) is resolved here as an ArgumentSpec(type: :identifier) and merged
354
+ # into ctx, exactly like any other keyword argument received by the action.
355
+ # @param spec [CommandSpec] a leaf node (no children)
356
+ # @param ctx [Hash] accumulated context
357
+ # @return [Object]
358
+ def execute_leaf(spec, ctx)
359
+ a = action_for(spec)
360
+ # Always resolve declared arguments (even when transfer_paths is set — those arguments
361
+ # are consumed first; ts_source_paths then reads whatever remains in the queue).
362
+ (spec.arguments || []).each do |arg_spec|
363
+ if arg_spec.type.eql?(:identifier)
364
+ unless ctx.key?(arg_spec.name)
365
+ lookup_method = arg_spec.lookup
366
+ block = lookup_method ? ->(f, v){send(lookup_method, f, v, **ctx)} : nil
367
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec, &block))
368
+ end
369
+ else
370
+ ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
371
+ end
372
+ end
373
+ invoke_action(a, [], ctx)
374
+ end
375
+
376
+ # Expand an entity_execute shorthand from a CommandSpec.
377
+ # Calls Base#entity_execute with the parameters from spec.entity_execute merged
378
+ # with the context hash (context entries are low-priority: spec params win).
379
+ # @param spec [CommandSpec] the command spec carrying entity_execute: Hash
380
+ # @param ctx [Hash] accumulated context (e.g. api:, lookup block)
381
+ # @return [Object]
382
+ def run_entity_execute(spec, ctx)
383
+ ee_params = spec.entity_execute.dup
384
+ # Resolve api: Symbol at runtime: :@ivar -> instance_variable_get, :method -> send
385
+ if (api_ref = ee_params[:api]).is_a?(Symbol)
386
+ ee_params[:api] = if api_ref.to_s.start_with?('@')
387
+ instance_variable_get(api_ref)
388
+ else
389
+ send(api_ref)
390
+ end
391
+ end
392
+ # Merge context into params (spec wins on key collision)
393
+ merged = ctx.merge(ee_params)
394
+ # Extract lookup_block before passing to entity_execute (it is not a kwarg of entity_execute)
395
+ block = merged.delete(:lookup_block)
396
+ if block
397
+ entity_execute(**merged, &block)
398
+ else
399
+ entity_execute(**merged)
400
+ end
401
+ end
402
+
403
+ # Resolve a single positional argument from the CLI argument stream.
404
+ # When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk).
405
+ # For type: :identifier, an optional block provides the percent-selector lookup.
406
+ # @param arg_spec [ArgumentSpec]
407
+ # @yieldparam field [String] field name from a percent-selector (%field:value)
408
+ # @yieldparam value [String] value from a percent-selector
409
+ # @yieldreturn [String] resolved identifier
410
+ # @return [Object] the resolved value, or Array when arg_spec.bulk is true
411
+ def resolve_argument(arg_spec, &block)
412
+ if arg_spec.bulk
413
+ is_bulk = options.get_option(:bulk)
414
+ if arg_spec.type.eql?(:identifier)
415
+ val = options.instance_identifier(description: arg_spec.name.to_s, &block)
416
+ else
417
+ val = options.get_next_argument(
418
+ arg_spec.name.to_s,
419
+ mandatory: arg_spec.mandatory,
420
+ validation: is_bulk ? Array : arg_spec.type,
421
+ default: arg_spec.default,
422
+ schema: arg_spec.schema
423
+ )
424
+ if is_bulk
425
+ Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument){'type'} unless arg_spec.type.nil?
426
+ end
427
+ end
428
+ # Always return an Array when bulk: true
429
+ is_bulk ? val : [val]
430
+ else
431
+ case arg_spec.type
432
+ when :identifier
433
+ options.instance_identifier(description: arg_spec.name.to_s, &block)
434
+ else
435
+ # Class or Array<Class> -> pass as validation type
436
+ # When interactive: true, set ask_missing_mandatory so that get_interactive is triggered
437
+ # when no CLI arguments are provided (mandatory is forced to true for the same reason:
438
+ # a non-nil default would short-circuit get_interactive before it is ever called).
439
+ options.ask_missing_mandatory = true if arg_spec.interactive
440
+ options.get_next_argument(
441
+ arg_spec.name.to_s,
442
+ mandatory: arg_spec.interactive ? true : arg_spec.mandatory,
443
+ multiple: arg_spec.multiple || false,
444
+ validation: arg_spec.type,
445
+ accept_list: arg_spec.allowed,
446
+ default: arg_spec.interactive ? nil : arg_spec.default,
447
+ schema: arg_spec.schema
448
+ )
449
+ end
450
+ end
451
+ end
452
+
453
+ # Build a nested Hash tree of the registered command tree for help display.
454
+ # Conditional commands are included with a '[condition_name]' annotation.
455
+ # @param path [Array<Symbol>] starting path ([] for the full tree)
456
+ # @return [Hash] { command_id => { description:, condition:, children: } }
457
+ def generate_help(path = [])
458
+ self.class.command_registry.children_of(path).transform_values do |child_spec|
459
+ annotation = child_spec.condition ? " [#{child_spec.condition}]" : ''
460
+ {
461
+ description: "#{child_spec.description}#{annotation}",
462
+ condition: child_spec.condition,
463
+ children: generate_help(child_spec.full_path)
464
+ }
107
465
  end
108
466
  end
109
467
 
@@ -111,13 +469,24 @@ module Aspera
111
469
  # @param api [Aspera::Rest] API to use
112
470
  # @param entity [String] Sub path in URL to resource relative to base url
113
471
  # @param command [Symbol, nil] Command to execute: :create, :show, :list, :modify, :delete
472
+ # When nil, reads the next command token from the CLI (fallback, deprecated).
114
473
  # @param display_fields [Array, nil] Fields to display by default
115
474
  # @param items_key [String, nil] Result is in a sub key of the JSON
116
475
  # @param delete_style [String, nil] If set, the delete operation by array in payload
117
476
  # @param id_as_arg [Boolean, String] If set, the id is provided as url argument ?<id_as_arg>=<id>
118
477
  # @param is_singleton [Boolean] If `true`, entity is the full path to the resource
119
478
  # @param list_query [Hash, nil] Query parameters for list operation
120
- # @yieldparam value [String] Value to search for identifier
479
+ # @param schema [String, nil] JSON schema name for create/modify validation
480
+ # @param query_component [String, nil] Registry component key (e.g. 'faspex', 'aoc') used to derive
481
+ # the query parameters schema path from the entity name. When set, `--query=help` on list/delete
482
+ # commands displays the available filter parameters from the OpenAPI spec.
483
+ # @param input_data [Array, Hash, nil] Pre-resolved data for :create (Array) or :modify (Hash).
484
+ # When nil, data is read from the CLI (fallback, deprecated).
485
+ # @param res_id [String, Array, nil] Pre-resolved resource identifier(s) for instance commands.
486
+ # When nil, identifier is read from the CLI (fallback, deprecated).
487
+ # @param is_bulk [Boolean] Whether operation runs in bulk mode (used when data: is provided)
488
+ # @param bfail [Boolean] When false, errors are captured as status strings instead of re-raised
489
+ # @yieldparam value [String] Value to search for identifier (lookup block)
121
490
  # @yieldreturn [String] The identifier
122
491
  # @return [Hash] Result suitable for CLI result
123
492
  def entity_execute(
@@ -130,25 +499,44 @@ module Aspera
130
499
  id_as_arg: false,
131
500
  is_singleton: false,
132
501
  list_query: nil,
502
+ schema: nil,
503
+ query_component: nil,
504
+ input_data: nil,
505
+ res_id: nil,
506
+ is_bulk: false,
507
+ bfail: true,
133
508
  &block
134
509
  )
510
+ # Fallback: read command from CLI when not provided (entity_command shorthand without command:)
135
511
  command = options.get_next_command(Operations::ALL) if command.nil?
512
+ # Derive query schema path from component + entity when component is given
513
+ qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
514
+
136
515
  if is_singleton
137
516
  one_res_path = entity
138
517
  elsif Operations::INSTANCE.include?(command)
139
- one_res_id = options.instance_identifier(&block)
518
+ # Use pre-resolved identifier if provided; otherwise fall back to CLI read (entity_command shorthand)
519
+ one_res_id = res_id || options.instance_identifier(&block)
140
520
  one_res_path = "#{entity}/#{one_res_id}"
141
521
  one_res_path = "#{entity}?#{id_as_arg}=#{one_res_id}" if id_as_arg
142
522
  end
143
523
 
144
524
  case command
145
525
  when :create
146
- raise BadArgument, 'cannot create singleton' if is_singleton
147
- return do_bulk_operation(command: command, descr: 'data', fields: display_fields) do |params|
526
+ Aspera.assert(!is_singleton, type: BadArgument){'cannot create singleton'}
527
+ unless input_data
528
+ # No pre-resolved data: read from CLI
529
+ cli_is_bulk = options.get_option(:bulk)
530
+ raw = options.get_next_argument('data', validation: cli_is_bulk ? Array : Hash, schema: schema)
531
+ input_data = cli_is_bulk ? raw : [raw]
532
+ is_bulk = cli_is_bulk
533
+ bfail = options.get_option(:bfail)
534
+ end
535
+ return Result.bulk(input_data, is_bulk: is_bulk, command: command, fields: display_fields, bfail: bfail) do |params|
148
536
  api.create(entity, params)
149
537
  end
150
538
  when :delete
151
- raise BadArgument, 'cannot delete singleton' if is_singleton
539
+ Aspera.assert(!is_singleton, type: BadArgument){'cannot delete singleton'}
152
540
  if !delete_style.nil?
153
541
  one_res_id = [one_res_id] unless one_res_id.is_a?(Array)
154
542
  Aspera.assert_type(one_res_id, Array, type: Cli::BadArgument)
@@ -158,17 +546,18 @@ module Aspera
158
546
  content_type: Mime::JSON,
159
547
  body: {delete_style => one_res_id}
160
548
  )
161
- return Main.result_status('deleted')
549
+ return Result::Status.new('deleted')
162
550
  end
163
- return do_bulk_operation(command: command, values: one_res_id) do |one_id|
164
- api.delete("#{entity}/#{one_id}", query_read_delete)
551
+ items = one_res_id.is_a?(Array) ? one_res_id : [one_res_id]
552
+ return Result.bulk(items, is_bulk: is_bulk, command: command, bfail: bfail) do |one_id|
553
+ api.delete("#{entity}/#{one_id}", query_read_delete(schema: qs_path))
165
554
  {'id' => one_id}
166
555
  end
167
556
  when :show
168
- return Main.result_single_object(api.read(one_res_path), fields: display_fields)
557
+ return Result::SingleObject.new(api.read(one_res_path), fields: display_fields)
169
558
  when :list
170
- data, http = api.read(entity, query_read_delete, ret: :both)
171
- return Main.result_empty if http.code == '204'
559
+ data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
560
+ return Result::Empty.new if http.code == '204'
172
561
  # TODO: not generic : which application is this for ?
173
562
  if http['Content-Type'].start_with?('application/vnd.api+json')
174
563
  Log.log.debug('is vnd.api')
@@ -177,17 +566,18 @@ module Aspera
177
566
  data = data[items_key] if items_key
178
567
  case data
179
568
  when Hash
180
- return Main.result_single_object(data, fields: display_fields)
569
+ return Result::SingleObject.new(data, fields: display_fields)
181
570
  when Array
182
- return Main.result_object_list(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
183
- return Main.result_value_list(data)
571
+ return Result::ObjectList.new(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
572
+ return Result::ValueList.new(data)
184
573
  else
185
574
  Aspera.error_unexpected_value(data.class.name){'list type'}
186
575
  end
187
576
  when :modify
188
- parameters = value_create_modify(command: command)
577
+ # Use pre-resolved data if provided; otherwise read from CLI
578
+ parameters = input_data || options.get_next_argument('data', validation: Hash, schema: schema)
189
579
  api.update(one_res_path, parameters)
190
- return Main.result_status('modified')
580
+ return Result::Status.new('modified')
191
581
  else
192
582
  Aspera.error_unexpected_value(command){'command'}
193
583
  end
@@ -195,10 +585,11 @@ module Aspera
195
585
 
196
586
  # Query parameters in URL suitable for REST: list/`GET` and delete/`DELETE`
197
587
  # @param default [Hash, nil] Default query parameters
588
+ # @param schema [String, nil] Contextual schema path for --query help display
198
589
  # @return [Hash, nil] Query parameters
199
- def query_read_delete(default: nil)
590
+ def query_read_delete(default: nil, schema: nil)
200
591
  # Dup default, as it could be frozen
201
- query = options.get_option(:query) || default.dup
592
+ query = options.get_option(:query, schema: schema) || default&.dup
202
593
  Log.log.debug{"query_read_delete=#{query}".bg_red}
203
594
  begin
204
595
  # Check it is suitable
@@ -208,34 +599,6 @@ module Aspera
208
599
  end
209
600
  return query
210
601
  end
211
-
212
- # Retrieves an extended value from command line.
213
- # Used for creation or modification of entities.
214
- # @param command [Symbol] Command name for error message
215
- # @param description [String, nil] Description of the value
216
- # @param type [Class, nil] Expected type of value
217
- # @param bulk [Boolean] If `true`, value must be an Array of `type`
218
- # @param default [Object, nil] Default value if not provided
219
- # @param schema [Hash, nil] JSON schema for validation
220
- # @return [Hash, Array<Hash>] The value(s) to create object(s)
221
- def value_create_modify(command:, description: nil, type: Hash, bulk: false, default: nil, schema: nil)
222
- value = options.get_next_argument(
223
- "parameters for #{command}#{" (#{description})" unless description.nil?}",
224
- mandatory: default.nil?,
225
- validation: bulk ? Array : type,
226
- schema: schema
227
- )
228
- value = default if value.nil?
229
- unless type.nil?
230
- Aspera.assert_type(type, Class){'type'}
231
- if bulk
232
- Aspera.assert_array_all(value, type, type: Cli::BadArgument){'type'}
233
- else
234
- Aspera.assert_type(value, type, type: Cli::BadArgument){'type'}
235
- end
236
- end
237
- return value
238
- end
239
602
  end
240
603
  end
241
604
  end