Class: Aspera::Cli::Plugins::Base

Inherits:
Object
  • Object
show all
Defined in:
lib/aspera/cli/plugins/base.rb

Overview

Base class for command plugins

Direct Known Subclasses

Ats, BasicAuth, Config, Cos, Httpgw, Mcp

Defined Under Namespace

Modules: Operations

Constant Summary collapse

FILTER_ARGS =

Shared positional argument for commands that accept an optional file name filter. Accepted types: String (shell glob matched against entry name), Regexp, or Proc. Used by node files find, and preview scan/events/trevents.

[{name: :filter, type: [String, Regexp, Proc], description: 'File name filter: String (glob), Regexp, or Proc', mandatory: false, default: nil}].freeze

Class Attribute Summary collapse

Instance Attribute Summary collapse

Class Method Summary collapse

Instance Method Summary collapse

Constructor Details

#initialize(context:) ⇒ Base

Returns a new instance of Base.



284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
# File 'lib/aspera/cli/plugins/base.rb', line 284

def initialize(context:)
  Aspera.assert_type(context, Context) { 'context' }
  Aspera.assert_type(context.man_header, TrueClass, FalseClass) { 'context.man_header' }
  @context = context
  # Switch to the plugin-specific options group so that all options declared
  # below (DSL-registered and imperative) appear under the plugin section in
  # --help output, separate from the global options.
  options.group(self.class.name.split('::').last.downcase) if @context.man_header
  # Auto-declare all options registered via the DSL `option` class method.
  # Walk the ancestor chain so that options declared on parent plugin classes
  # (e.g. Oauth, BasicAuth) are also registered for sub-classes (e.g. Aoc).
  # The options object is shared across all plugins in a run; skip options already
  # declared by an earlier plugin (Base.option prevents duplicates within one hierarchy).
  # Each OptionSpec is translated to an options.declare call, resolving the
  # handler: shorthand:
  #   Symbol handler: {o: self, m: <symbol>}  (Category B - plugin instance methods)
  #   Hash handler:   used as-is              (Category A - singletons / class constants)
  sources = []
  self.class.ancestors.each do |klass|
    next unless klass.is_a?(Class) && klass <= Base
    sources << klass if klass.instance_variable_defined?(:@command_registry)
    sources.concat(klass.used_option_sources) if klass.respond_to?(:used_option_sources)
  end
  sources.uniq.each do |src|
    specs =
      if src.respond_to?(:command_registry)
        src.command_registry.option_specs
      elsif src.respond_to?(:option_specs)
        src.option_specs
      else
        {}
      end
    specs.each_value do |spec|
      next if options.option_declared?(spec.name)
      resolved_handler =
        case spec.handler
        when Symbol then {o: self, m: spec.handler}
        when Hash   then spec.handler
        end
      options.declare(
        spec.name,
        description: spec.description,
        short:       spec.short,
        allowed:     spec.allowed,
        default:     spec.default,
        handler:     resolved_handler,
        deprecation: spec.deprecation,
        schema:      spec.schema
      )
    end
  end
end

Class Attribute Details

.root_setup_methodSymbol? (readonly)

Returns:

  • (Symbol, nil)


249
250
251
# File 'lib/aspera/cli/plugins/base.rb', line 249

def root_setup_method
  @root_setup_method
end

Instance Attribute Details

#contextObject (readonly)

Global objects



338
339
340
# File 'lib/aspera/cli/plugins/base.rb', line 338

def context
  @context
end

#help_pathObject (readonly)

Path reached in the command tree at the moment --help was intercepted. Nil until set by dispatch_from_registry.



341
342
343
# File 'lib/aspera/cli/plugins/base.rb', line 341

def help_path
  @help_path
end

Class Method Details

.application_name(name = nil) ⇒ String

DSL class method: declare the human-readable application name shown in wizards. When called with an argument, sets the name. When called with no argument, returns it. Falls back to the last component of the class name if never set.

Parameters:

  • name (String, nil) (defaults to: nil)

Returns:



256
257
258
259
# File 'lib/aspera/cli/plugins/base.rb', line 256

def application_name(name = nil)
  @application_name = name unless name.nil?
  @application_name || self.name.split('::').last
end

.command(id, **kwargs) ⇒ Object

DSL class method: register a command in this plugin's registry. Inherits parent from the enclosing commands_under block when parent: is omitted.

Parameters:

  • id (Symbol)
  • kwargs (Hash)

    forwarded to CommandSpec



47
48
49
50
# File 'lib/aspera/cli/plugins/base.rb', line 47

def command(id, **kwargs)
  kwargs[:parent] = @current_parent if kwargs[:parent].nil? && @current_parent
  command_registry.register(CommandSpec.new(id: id, **kwargs))
end

.command_registryObject



39
40
41
# File 'lib/aspera/cli/plugins/base.rb', line 39

def command_registry
  @command_registry ||= CommandRegistry.new
end

.commands_under(parent, description: nil) ⇒ Object

DSL class method: scope block that sets a default parent for nested command() calls. Fully re-entrant: blocks may be nested for multi-level parent paths. If the terminal node of parent has not been declared yet, it is auto-declared as an intermediate command with description: "Manage " (or the given description:).

parent is always resolved relative to the current scope:

Array(@current_parent) + Array(parent)

Parameters:

  • parent (Symbol, Array<Symbol>)

    one or more path segments, relative to current scope

  • description (String, nil) (defaults to: nil)

    Description of entity for the auto-declared node

Yield Returns:

  • (void)


137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
# File 'lib/aspera/cli/plugins/base.rb', line 137

def commands_under(parent, description: nil)
  # Always relative: append the given segments to the current scope.
  path = Array(@current_parent) + Array(parent)
  unless command_registry[path]
    id = path.last
    desc = description || "Manage #{entity_display_name(id)}"
    parent_path = path[0..-2]
    saved = @current_parent
    @current_parent = parent_path.empty? ? nil : parent_path
    command(id, description: desc)
    @current_parent = saved
  end
  previous = @current_parent
  @current_parent = path
  yield
ensure
  @current_parent = previous
end

.crud_commands(api:, entity:, operations: nil, name: nil, lookup: nil, **kwargs) ⇒ Object

DSL class method: declare CRUD commands for a REST entity.

For each verb in operations:, registers one CommandSpec with:

- description: "#{verb.capitalize} #{name}"
- arguments:   [{name: :id, type: :identifier, lookup: lookup}] for instance verbs
             (:show, :modify, :delete) when not a singleton; none for global verbs
- action:      calls entity_<verb>(api:, entity:, **shared_kwargs, **ctx)

api: is resolved at runtime: :@ivar -> instance_variable_get, else -> send. entity: may also be a Symbol — resolved at runtime as a ctx key (e.g. :sf_entity). This covers cases where the entity path is injected by a parent setup: method.

Parameters:

  • api (Symbol, String)

    Runtime API ref (:@ivar or method name) or literal string

  • entity (String, Symbol)

    REST sub-path, or ctx key Symbol resolved at runtime

  • operations (Array<Symbol>) (defaults to: nil)

    Verbs to expose; defaults to Operations::ALL

  • name (String, nil) (defaults to: nil)

    Display name; defaults to last segment of entity (static only)

  • lookup (Symbol, nil) (defaults to: nil)

    Instance method for percent-selector resolution

  • kwargs (Hash)

    Shared params forwarded to every per-verb method



77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
# File 'lib/aspera/cli/plugins/base.rb', line 77

def crud_commands(api:, entity:, operations: nil, name: nil, lookup: nil, **kwargs)
  name       ||= entity_display_name(entity) unless entity.is_a?(Symbol)
  operations ||= Operations::ALL
  operations.each do |verb|
    id_arg = ({name: :id, type: :identifier, lookup: lookup} if Operations::INSTANCE.include?(verb) && !kwargs[:is_singleton])
    schema_val =
      if kwargs[:body_component] && entity.is_a?(String)
        case verb
        when :create then Schema::Registry.req_body(kwargs[:body_component], "#{entity}.post")
        when :modify then Schema::Registry.req_body(kwargs[:body_component], "#{entity}/{id}.put")
        end
      end
    args =
      case verb
      when :create
        [{name: :data, type: Hash, bulk: true, schema: schema_val}]
      when :modify
        [id_arg, {name: :data, type: Hash, schema: schema_val}].compact
      when :delete
        id_arg ? [id_arg.merge(bulk: true)] : nil
      else
        id_arg ? [id_arg] : nil
      end
    action_proc = lambda do |**ctx|
      resolved_api =
        if api.is_a?(Symbol)
          api.to_s.start_with?('@') ? instance_variable_get(api) : send(api)
        elsif api.is_a?(Proc)
          instance_exec(&api)
        else
          api
        end
      resolved_entity = entity.is_a?(Symbol) ? ctx.fetch(entity) : entity
      send(:"entity_#{verb}", api: resolved_api, entity: resolved_entity, **kwargs, **ctx)
    end
    cmd_attrs = {description: "#{verb.capitalize} #{name || entity.inspect}", action: action_proc}
    cmd_attrs[:arguments] = args if args
    command(verb, **cmd_attrs)
  end
end

.declare_options(options, parse: false) ⇒ Object

Declare all options registered on this plugin class onto a Parser instance. Walks inherited options and any sources added via use_options.

Parameters:

  • options (Aspera::Cli::Parser)
  • parse (Boolean) (defaults to: false)

    whether to call parse_options! after declaring



202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
# File 'lib/aspera/cli/plugins/base.rb', line 202

def declare_options(options, parse: false)
  sources = []
  ancestors.each do |klass|
    next unless klass.is_a?(Class) && klass <= Base
    sources << klass if klass.instance_variable_defined?(:@command_registry)
    sources.concat(klass.used_option_sources) if klass.respond_to?(:used_option_sources)
  end
  sources.uniq.each do |src|
    specs =
      if src.respond_to?(:command_registry)
        src.command_registry.option_specs
      elsif src.respond_to?(:option_specs)
        src.option_specs
      else
        {}
      end
    specs.each_value do |spec|
      next if options.option_declared?(spec.name)
      resolved_handler =
        case spec.handler
        when Hash then spec.handler
        end
      options.declare(
        spec.name,
        description: spec.description,
        short:       spec.short,
        allowed:     spec.allowed,
        default:     spec.default,
        handler:     resolved_handler,
        deprecation: spec.deprecation,
        schema:      spec.schema
      )
    end
  end
  options.parse_options! if parse
end

.define_action_method(path) {|keyword| ... } ⇒ Object

DSL class method: define an instance method whose name is derived from a path array. Equivalent to: define_method(CommandSpec.action_method(path), &block)

Parameters:

  • path (Array<Symbol>)

    command path segments, e.g. [:admin, :user, :list]

Yield Parameters:

  • keyword (Hash)

    context forwarded from dispatch



122
123
124
# File 'lib/aspera/cli/plugins/base.rb', line 122

def define_action_method(path, &block)
  define_method(CommandSpec.action_method(path), &block)
end

.entity_display_name(entity) ⇒ Object

Derive a display name from an entity path: last segment after '/', underscores replaced by spaces, first letter capitalized. e.g. 'data/smtp_server' -> 'Smtp server', 'data/transfer_settings' -> 'Transfer settings'



55
56
57
# File 'lib/aspera/cli/plugins/base.rb', line 55

def entity_display_name(entity)
  entity.to_s.split('/').last.tr('_', ' ').capitalize
end

.file_matcher(match_expression) ⇒ Proc

Build a filter lambda from a match expression (String glob, Regexp, Proc, or nil).

Parameters:

  • match_expression (String, Regexp, Proc, NilClass)

    as in FILTER_ARGS

Returns:

  • (Proc)

    lambda(entry) -> Boolean



264
265
266
267
268
269
270
271
272
# File 'lib/aspera/cli/plugins/base.rb', line 264

def file_matcher(match_expression)
  case match_expression
  when Proc    then match_expression
  when Regexp  then ->(f) { f['name'].match?(match_expression) }
  when String  then ->(f) { File.fnmatch(match_expression, f['name'], File::FNM_DOTMATCH) }
  when NilClass then ->(_) { true }
  else Aspera.error_unexpected_value(match_expression.class.name, type: ParameterError)
  end
end

.option(name, description: nil, short: nil, allowed: nil, default: nil, handler: nil, deprecation: nil, schema: nil) ⇒ Object

DSL class method: declare an option in this plugin's registry. Metadata is stored as an OptionSpec at class-load time; the actual options.declare call happens in Base#initialize once the instance exists.

Raises ArgumentError at class-load time if the same option name is already declared by any ancestor class, preventing silent shadowing.

Parameters:

  • name (Symbol)

    Option name

  • description (String, nil) (defaults to: nil)

    User-facing description; if nil, derived from schema: title/description

  • short (String, nil) (defaults to: nil)

    Single-character short form (without leading '-')

  • allowed (Object, nil) (defaults to: nil)

    Allowed values (see OptionValue)

  • default (Object, nil) (defaults to: nil)

    Default value

  • handler (Symbol, Hash, nil) (defaults to: nil)
    • Symbol: resolved to , m: at runtime (Category B)
    • Hash: , m: used as-is (Category A: singletons / constants)
    • nil: option stores its value locally (no delegation)
    • deprecation (String, nil) (defaults to: nil)

      Deprecation message forwarded to options.declare

    • schema (String, nil) (defaults to: nil)

      Schema reference (e.g. "opts:components.schemas.Foo"); when description: is nil, the schema title or first description line is used

    • Raises:

      • (ArgumentError)
      
      
      175
      176
      177
      178
      179
      180
      181
      182
      183
      184
      185
      186
      187
      188
      189
      190
      191
      192
      193
      194
      195
      196
      # File 'lib/aspera/cli/plugins/base.rb', line 175
      
      def option(name, description: nil,
        short: nil, allowed: nil, default: nil,
        handler: nil, deprecation: nil, schema: nil)
        ancestor_owner = ancestors.drop(1).find do |klass|
          klass.is_a?(Class) && klass <= Base &&
            klass.instance_variable_defined?(:@command_registry) &&
            klass.command_registry.option_specs.key?(name)
        end
        raise ArgumentError, "#{self}: option :#{name} already declared in ancestor #{ancestor_owner}" if ancestor_owner
        command_registry.register_option(
          OptionSpec.new(
            name:        name,
            description: description,
            short:       short,
            allowed:     allowed,
            default:     default,
            handler:     handler,
            deprecation: deprecation,
            schema:      schema
          )
        )
      end

      .root_setup(method_name) ⇒ Object

      DSL class method: declare a setup method to run once before root dispatch. The method is called before any command is consumed, and its return value (a Hash) is merged into the initial ctx. This is useful when conditions on root commands depend on state built during setup (e.g. @connection_type).

      Parameters:

      • method_name (Symbol)
      
      
      244
      245
      246
      # File 'lib/aspera/cli/plugins/base.rb', line 244
      
      def root_setup(method_name)
        @root_setup_method = method_name
      end

      .use_options(source) ⇒ Object

      Include options from another plugin or OptionDeclarator module.

      Parameters:

      • source (Class, Module)
      
      
      35
      36
      37
      # File 'lib/aspera/cli/plugins/base.rb', line 35
      
      def use_options(source)
        used_option_sources << source unless used_option_sources.include?(source)
      end

      .used_option_sourcesCommandRegistry

      Per-class DSL registry (not inherited: each subclass gets its own instance).

      Returns:

      
      
      29
      30
      31
      # File 'lib/aspera/cli/plugins/base.rb', line 29
      
      def used_option_sources
        @used_option_sources ||= []
      end

      Instance Method Details

      #action_for(spec) ⇒ Symbol, Proc

      Resolve the action for a leaf CommandSpec. Returns spec.action (Symbol or Proc) if explicitly set; otherwise derives a Symbol from the full path as :action_<path_segment_1><path_segment_2>... (e.g. [:access_key, :list] -> :action_access_key_list).

      Parameters:

      Returns:

      • (Symbol, Proc)
      
      
      503
      504
      505
      # File 'lib/aspera/cli/plugins/base.rb', line 503
      
      def action_for(spec)
        spec.action || spec.action_method_name
      end

      #add_manual_header(_has_options = true) ⇒ Object

      
      
      360
      361
      362
      363
      # File 'lib/aspera/cli/plugins/base.rb', line 360
      
      def add_manual_header(_has_options = true)
        # No-op: the group is set at the start of initialize.
        # Kept for compatibility with Config, which calls add_manual_header(false) from Runner.
      end

      #bulk_result(items, command:, id_result: 'id', fields: :default) {|item| ... } ⇒ Result::ObjectList, Result::SingleObject

      Convenience wrapper: reads :bulk and :bfail from options, normalises items to an Array, then delegates to Result.bulk. Use this in action methods instead of the three-line boilerplate:

      is_bulk = options.get_option(:bulk)
      items   = x.is_a?(Array) ? x : [x]
      Result.bulk(items, is_bulk: is_bulk, ...)

      Parameters:

      • items (Object, Array)

        Single item or Array; wrapped in Array when needed

      • command (Symbol)

        Operation name (:create, :delete, ...)

      • id_result (String) (defaults to: 'id')

        Key used as item identifier in the result row

      • fields (Object) (defaults to: :default)

        Fields hint passed to Result constructor (non-bulk only)

      Yield Parameters:

      • item (Object)

        Each item in items

      Returns:

      
      
      628
      629
      630
      631
      632
      633
      634
      635
      636
      637
      638
      639
      # File 'lib/aspera/cli/plugins/base.rb', line 628
      
      def bulk_result(items, command:, id_result: 'id', fields: :default, &block)
        items = items.is_a?(Array) ? items : [items]
        Result.bulk(
          items,
          is_bulk:   options.get_option(:bulk),
          command:   command,
          id_result: id_result,
          fields:    fields,
          bfail:     options.get_option(:bfail),
          &block
        )
      end

      #configAspera::Cli::Plugins::Config

      
      
      348
      # File 'lib/aspera/cli/plugins/base.rb', line 348
      
      def config; @context.config; end

      #dispatch_child(current_path, registry, ctx) ⇒ Object

      Phase B, child branch: consume the next command argument, resolve the matching child spec, handle delegation, and recurse or execute. --help is intercepted at two points:

      1. Before get_next_command when no positional arg is pending: raises HelpRequest
       immediately so the subcommand list with descriptions is shown rather than a
       MissingArgument error.
      2. After get_next_command when no further args remain: raises HelpRequest scoped
       to the consumed command (e.g. `aoc files find -h`).

      Parameters:

      Returns:

      • (Object)
      
      
      462
      463
      464
      465
      466
      467
      468
      469
      470
      471
      472
      473
      474
      475
      476
      477
      478
      479
      480
      481
      482
      483
      484
      485
      486
      487
      488
      489
      490
      491
      492
      493
      494
      495
      # File 'lib/aspera/cli/plugins/base.rb', line 462
      
      def dispatch_child(current_path, registry, ctx)
        children  = registry.children_of(current_path)
        available = children.reject { |_, c| c.condition && !send(c.condition) }
        aliases   = children.values.each_with_object({}) do |c, h|
          Array(c.aliases).each { |a| h[a] = c.id } if c.aliases
        end
      
        # Intercept --help before consuming the command token when no arg is pending.
        # This avoids MissingArgument being raised by get_next_command before HelpRequest.
        if options.help_requested && options.command_or_arg_empty?
          @help_path = current_path
          raise Cli::HelpRequest, self
        end
      
        command = options.get_next_command(available.keys, aliases: aliases.empty? ? nil : aliases)
        child   = available[command]
      
        # Intercept --help after a command was consumed but no further args remain.
        # (e.g. `aoc files find -h`). When further args remain, keep recursing.
        if options.help_requested && options.command_or_arg_empty?
          @help_path = current_path + [command]
          raise Cli::HelpRequest, self
        end
      
        # Instance delegation: hand off to a different plugin object
        if child.delegate_instance
          target = send(child.delegate_instance)
          return target.dispatch_from_registry(Array(child.delegates_to), {})
        end
        return dispatch_from_registry(Array(child.delegates_to), ctx) if child.delegates_to
      
        # Both intermediate and leaf: instance_arg + setup are handled by Phase A of the next call
        dispatch_from_registry(current_path + [command], ctx)
      end

      #dispatch_from_registry(current_path, ctx = {}, skip_setup: false) ⇒ Object

      Two-phase dispatcher: run setup on the current node (Phase A), then either execute a leaf directly or consume the next argument and recurse (Phase B).

      Parameters:

      • current_path (Array<Symbol>)

        path of the node currently being dispatched

      • ctx (Hash) (defaults to: {})

        accumulated context passed down from parent nodes

      • skip_setup (Boolean) (defaults to: false)

        when true, skip Phase A (setup already done by caller)

      Returns:

      • (Object)

        result suitable for CLI output

      
      
      390
      391
      392
      393
      394
      395
      396
      397
      398
      399
      400
      401
      402
      403
      404
      405
      406
      407
      408
      409
      410
      411
      412
      413
      414
      415
      416
      417
      418
      419
      420
      421
      422
      423
      424
      425
      426
      427
      428
      429
      430
      431
      432
      433
      434
      # File 'lib/aspera/cli/plugins/base.rb', line 390
      
      def dispatch_from_registry(current_path, ctx = {}, skip_setup: false)
        registry = self.class.command_registry
        spec     = registry[current_path]
        is_leaf  = spec && registry.children_of(current_path).empty?
      
        if options.help_requested || skip_setup
          # help_requested on an intermediate node: drain positional args without validation
          # so that dispatch_child can still consume the correct sub-command token
          if !is_leaf && !skip_setup && spec&.arguments
            spec.arguments.each do |arg_spec|
              next if ctx.key?(arg_spec.name)
              options.get_next_argument(arg_spec.name.to_s, mandatory: false)
            end
          end
        else
          # Phase A - for intermediate nodes only: resolve all ArgumentSpec declared on this node
          # before dispatching to children (leaf nodes resolve their arguments inside execute_leaf).
          if !is_leaf
            (spec&.arguments || []).each do |arg_spec|
              next if ctx.key?(arg_spec.name)
              if arg_spec.type.eql?(:identifier)
                lookup_cb = arg_spec.lookup
                res_id = if lookup_cb.nil?
                  options.instance_identifier(description: arg_spec.name.to_s)
                elsif lookup_cb.is_a?(Symbol)
                  options.instance_identifier(description: arg_spec.name.to_s) { |f, v| send(lookup_cb, f, v, **ctx) }
                else
                  options.instance_identifier(description: arg_spec.name.to_s) { |f, v| instance_exec(f, v, **ctx, &lookup_cb) }
                end
                ctx = ctx.merge(arg_spec.name => res_id)
              else
                ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
              end
            end
          end
          ctx = ctx.merge(send(spec.setup, **ctx)) if spec&.setup
        end
      
        # Phase B - leaf fast-path or child dispatch
        if is_leaf
          dispatch_leaf(current_path, spec, ctx)
        else
          dispatch_child(current_path, registry, ctx)
        end
      end

      #dispatch_leaf(current_path, spec, ctx) ⇒ Object

      Phase B, leaf branch: execute a spec that is already a leaf (no children). Intercepts --help before calling execute_leaf.

      Parameters:

      Returns:

      • (Object)
      
      
      442
      443
      444
      445
      446
      447
      448
      # File 'lib/aspera/cli/plugins/base.rb', line 442
      
      def dispatch_leaf(current_path, spec, ctx)
        if options.help_requested
          @help_path = current_path
          raise Cli::HelpRequest, self
        end
        execute_leaf(spec, ctx)
      end

      #entity_create(api:, entity:, display_fields: nil, body_component: nil, input_data: nil, data: nil) ⇒ Object

      Create one or more instances of an entity (supports bulk).

      Parameters:

      • api (Aspera::Rest)

        REST API object

      • entity (String)

        API sub-path

      • display_fields (Array, nil) (defaults to: nil)

        Fields to display

      • body_component (String, nil) (defaults to: nil)

        Registry key for request body schema

      • input_data (Array, nil) (defaults to: nil)

        Pre-resolved data; when nil, read from CLI

      
      
      691
      692
      693
      694
      695
      696
      697
      698
      699
      700
      701
      702
      703
      # File 'lib/aspera/cli/plugins/base.rb', line 691
      
      def entity_create(api:, entity:, display_fields: nil, body_component: nil, input_data: nil, data: nil, **)
        schema = body_component ? Schema::Registry.req_body(body_component, "#{entity}.post") : nil
        input_data ||= data
        unless input_data
          is_bulk = options.get_option(:bulk)
          raw = options.get_next_argument('data', validation: is_bulk ? Array : Hash, schema: schema)
          input_data = is_bulk ? raw : [raw]
        end
        input_data = [input_data] unless input_data.is_a?(Array)
        bulk_result(input_data, command: :create, fields: display_fields) do |params|
          api.create(entity, params)
        end
      end

      #entity_delete(api:, entity:, id: nil, id_as_arg: false, delete_style: nil, query_component: nil) ⇒ Object

      Delete one or more instances of an entity (supports bulk).

      Parameters:

      • api (Aspera::Rest)

        REST API object

      • entity (String)

        API sub-path

      • id (String, Array, nil) (defaults to: nil)

        Resource identifier(s)

      • id_as_arg (Boolean, String) (defaults to: false)

        When set, id is appended as ?<id_as_arg>=

      • delete_style (String, nil) (defaults to: nil)

        When set, deletes by sending id array in payload

      • query_component (String, nil) (defaults to: nil)

        Registry key for --query=help schema

      
      
      728
      729
      730
      731
      732
      733
      734
      735
      736
      737
      738
      739
      740
      741
      742
      743
      # File 'lib/aspera/cli/plugins/base.rb', line 728
      
      def entity_delete(api:, entity:, id: nil, id_as_arg: false, delete_style: nil, query_component: nil, **)
        qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
        if !delete_style.nil?
          ids = id.is_a?(Array) ? id : [id]
          Aspera.assert_type(ids, Array, type: Cli::BadArgument)
          api.delete(entity, nil, content_type: Mime::JSON, body: {delete_style => ids})
          return Result::Status.new('deleted')
        end
        bulk_result(id, command: :delete) do |one_id|
          api.delete(
            id_as_arg ? "#{entity}?#{id_as_arg}=#{one_id}" : "#{entity}/#{one_id}",
            query_read_delete(schema: qs_path)
          )
          {'id' => one_id}
        end
      end

      #entity_list(api:, entity:, display_fields: nil, items_key: nil, list_query: nil, query_component: nil) ⇒ Object

      List all instances of an entity.

      Parameters:

      • api (Aspera::Rest)

        REST API object

      • entity (String)

        API sub-path

      • display_fields (Array, nil) (defaults to: nil)

        Fields to display

      • items_key (String, nil) (defaults to: nil)

        Sub-key in response containing the array

      • list_query (Hash, nil) (defaults to: nil)

        Default query parameters

      • query_component (String, nil) (defaults to: nil)

        Registry key for --query=help schema

      
      
      654
      655
      656
      657
      658
      659
      660
      661
      662
      663
      664
      665
      666
      667
      668
      669
      670
      671
      # File 'lib/aspera/cli/plugins/base.rb', line 654
      
      def entity_list(api:, entity:, display_fields: nil, items_key: nil, list_query: nil, query_component: nil, **)
        qs_path = query_component ? Schema::Registry.query_params(query_component, entity) : nil
        data, http = api.read(entity, query_read_delete(default: list_query, schema: qs_path), ret: :both)
        return Result::Empty.new if http.code == '204'
        # TODO: not generic : which application is this for ?
        if http['Content-Type'].start_with?('application/vnd.api+json')
          Log.log.debug('is vnd.api')
          data = data[entity]
        end
        data = data[items_key] if items_key
        case data
        when Hash then Result::SingleObject.new(data, fields: display_fields)
        when Array
          return Result::ObjectList.new(data, fields: display_fields) if data.empty? || data.first.is_a?(Hash)
          Result::ValueList.new(data)
        else Aspera.error_unexpected_value(data.class.name) { 'list type' }
        end
      end

      #entity_modify(api:, entity:, id: nil, is_singleton: false, id_as_arg: false, body_component: nil, input_data: nil, data: nil) ⇒ Object

      Modify an existing instance of an entity.

      Parameters:

      • api (Aspera::Rest)

        REST API object

      • entity (String)

        API sub-path

      • id (String, nil) (defaults to: nil)

        Resource identifier; nil when is_singleton: true

      • is_singleton (Boolean) (defaults to: false)

        When true, entity is the full path (no id appended)

      • id_as_arg (Boolean, String) (defaults to: false)

        When set, id is appended as ?<id_as_arg>=

      • body_component (String, nil) (defaults to: nil)

        Registry key for request body schema

      • input_data (Hash, nil) (defaults to: nil)

        Pre-resolved data; when nil, read from CLI

      
      
      713
      714
      715
      716
      717
      718
      719
      # File 'lib/aspera/cli/plugins/base.rb', line 713
      
      def entity_modify(api:, entity:, id: nil, is_singleton: false, id_as_arg: false, body_component: nil, input_data: nil, data: nil, **)
        schema = body_component ? Schema::Registry.req_body(body_component, "#{entity}/{id}.put") : nil
        path = entity_res_path(entity, id, is_singleton: is_singleton, id_as_arg: id_as_arg)
        parameters = input_data || data || options.get_next_argument('data', validation: Hash, schema: schema)
        api.update(path, parameters)
        Result::Status.new('modified')
      end

      #entity_res_path(entity, id, is_singleton: false, id_as_arg: false) ⇒ String

      Build the resource path for an instance operation.

      Parameters:

      • entity (String)

        API sub-path

      • id (String, nil)

        Resource identifier

      • is_singleton (Boolean) (defaults to: false)

        When true, entity IS the full path

      • id_as_arg (Boolean, String) (defaults to: false)

        When set, id appended as ?<id_as_arg>=

      Returns:

      
      
      751
      752
      753
      754
      755
      # File 'lib/aspera/cli/plugins/base.rb', line 751
      
      def entity_res_path(entity, id, is_singleton: false, id_as_arg: false)
        return entity if is_singleton
        return "#{entity}?#{id_as_arg}=#{id}" if id_as_arg
        "#{entity}/#{id}"
      end

      #entity_show(api:, entity:, id: nil, display_fields: nil, is_singleton: false, id_as_arg: false) ⇒ Object

      Show one instance of an entity.

      Parameters:

      • api (Aspera::Rest)

        REST API object

      • entity (String)

        API sub-path

      • id (String, nil) (defaults to: nil)

        Resource identifier; nil when is_singleton: true

      • display_fields (Array, nil) (defaults to: nil)

        Fields to display

      • is_singleton (Boolean) (defaults to: false)

        When true, entity is the full path (no id appended)

      • id_as_arg (Boolean, String) (defaults to: false)

        When set, id is appended as ?<id_as_arg>=

      
      
      680
      681
      682
      683
      # File 'lib/aspera/cli/plugins/base.rb', line 680
      
      def entity_show(api:, entity:, id: nil, display_fields: nil, is_singleton: false, id_as_arg: false, **)
        path = entity_res_path(entity, id, is_singleton: is_singleton, id_as_arg: id_as_arg)
        Result::SingleObject.new(api.read(path), fields: display_fields)
      end

      #execute_actionObject

      Entry point for all DSL-based plugins.

      
      
      366
      367
      368
      369
      370
      371
      372
      373
      374
      375
      376
      377
      378
      379
      380
      381
      382
      # File 'lib/aspera/cli/plugins/base.rb', line 366
      
      def execute_action
        @help_path = nil
        # Validate the registry once per class (memoised by the ivar check).
        # Passes the plugin class so implicit handler methods can be verified.
        unless self.class.instance_variable_defined?(:@registry_validated)
          self.class.command_registry.validate!(plugin_class: self.class)
          self.class.instance_variable_set(:@registry_validated, true)
        end
        # Run the root setup (if declared) before consuming any argument.
        # This ensures condition methods on root commands can read instance variables
        # populated by the setup (e.g. @connection_type in server.rb).
        init_ctx = {}
        if (rsm = self.class.root_setup_method)
          init_ctx = send(rsm) || {}
        end
        dispatch_from_registry([], init_ctx)
      end

      #execute_leaf(spec, ctx) ⇒ Object

      Execute a leaf CommandSpec: resolve arguments and call action. Arguments already present in ctx (pre-resolved by a parent plugin, e.g. aoc.rb forwarding path: into execute_nodegen4_command) are skipped — the token has already been consumed. instance_arg (if any) is resolved here as an ArgumentSpec(type: :identifier) and merged into ctx, exactly like any other keyword argument received by the action.

      Parameters:

      • spec (CommandSpec)

        a leaf node (no children)

      • ctx (Hash)

        accumulated context (pre-resolved keys are not re-consumed)

      Returns:

      • (Object)
      
      
      530
      531
      532
      533
      534
      535
      536
      537
      538
      539
      540
      541
      542
      543
      544
      545
      546
      547
      548
      549
      # File 'lib/aspera/cli/plugins/base.rb', line 530
      
      def execute_leaf(spec, ctx)
        a = action_for(spec)
        # Always resolve declared arguments (even when transfer_paths is set — those arguments
        # are consumed first; ts_source_paths then reads whatever remains in the queue).
        (spec.arguments || []).each do |arg_spec|
          next if ctx.key?(arg_spec.name)
          if arg_spec.type.eql?(:identifier)
            lookup_cb = arg_spec.lookup
            block =
              if lookup_cb.nil? then nil
              elsif lookup_cb.is_a?(Symbol) then ->(f, v) { send(lookup_cb, f, v, **ctx) }
              else ->(f, v) { instance_exec(f, v, **ctx, &lookup_cb) }
              end
            ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec, &block))
          else
            ctx = ctx.merge(arg_spec.name => resolve_argument(arg_spec))
          end
        end
        invoke_action(a, [], ctx)
      end

      #formatterAspera::Cli::Formatter

      
      
      350
      # File 'lib/aspera/cli/plugins/base.rb', line 350
      
      def formatter; @context.formatter; end

      #generate_help(path = []) ⇒ Hash

      Build a nested Hash tree of the registered command tree for help display. Conditional commands are included with a '[condition_name]' annotation.

      Parameters:

      • path (Array<Symbol>) (defaults to: [])

        starting path ([] for the full tree)

      Returns:

      • (Hash)

        { command_id => { description:, condition:, children: } }

      
      
      605
      606
      607
      608
      609
      610
      611
      612
      613
      614
      # File 'lib/aspera/cli/plugins/base.rb', line 605
      
      def generate_help(path = [])
        self.class.command_registry.children_of(path).transform_values do |child_spec|
          annotation = child_spec.condition ? " [#{child_spec.condition}]" : ''
          {
            description: "#{child_spec.description}#{annotation}",
            condition:   child_spec.condition,
            children:    generate_help(child_spec.full_path)
          }
        end
      end

      #http_configAspera::Cli::Http

      Returns:

      
      
      356
      # File 'lib/aspera/cli/plugins/base.rb', line 356
      
      def http_config; @context.http_config; end

      #invoke_action(action, args, ctx) ⇒ Object

      Invoke an action (Symbol method or Proc block) with the given positional arguments and keyword context. Procs are executed via instance_exec so they share the plugin's self.

      Parameters:

      • action (Symbol, Proc)
      • args (Array)

        positional arguments

      • ctx (Hash)

        keyword context

      Returns:

      • (Object)
      
      
      514
      515
      516
      517
      518
      519
      520
      # File 'lib/aspera/cli/plugins/base.rb', line 514
      
      def invoke_action(action, args, ctx)
        if action.is_a?(Proc)
          instance_exec(*args, **ctx, &action)
        else
          send(action, *args, **ctx)
        end
      end

      #optionsAspera::Cli::Parser

      Returns:

      
      
      344
      # File 'lib/aspera/cli/plugins/base.rb', line 344
      
      def options; @context.options; end

      #persistencyAspera::PersistencyFolder

      
      
      352
      # File 'lib/aspera/cli/plugins/base.rb', line 352
      
      def persistency; @context.persistency; end

      #presetsAspera::Cli::PresetManager

      
      
      354
      # File 'lib/aspera/cli/plugins/base.rb', line 354
      
      def presets; @context.presets; end

      #progress_barAspera::Cli::TransferProgress?

      
      
      358
      # File 'lib/aspera/cli/plugins/base.rb', line 358
      
      def progress_bar; @context.progress_bar; end

      #query_read_delete(default: nil, schema: nil) ⇒ Hash?

      Query parameters in URL suitable for REST: list/GET and delete/DELETE

      Parameters:

      • default (Hash, nil) (defaults to: nil)

        Default query parameters

      • schema (String, nil) (defaults to: nil)

        Contextual schema path for --query help display

      Returns:

      • (Hash, nil)

        Query parameters

      
      
      761
      762
      763
      764
      765
      766
      767
      768
      769
      770
      771
      772
      # File 'lib/aspera/cli/plugins/base.rb', line 761
      
      def query_read_delete(default: nil, schema: nil)
        # Dup default, as it could be frozen
        query = options.get_option(:query, schema: schema) || default&.dup
        Log.dump(:query_read_delete, query)
        begin
          # Check it is suitable
          URI.encode_www_form(query) unless query.nil?
        rescue StandardError => e
          raise Cli::BadArgument, "Query must be an extended value (Hash, Array) which can be encoded with URI.encode_www_form. Refer to manual. (#{e.message})"
        end
        return query
      end

      #resolve_argument(arg_spec) {|field, value| ... } ⇒ Object

      Resolve a single positional argument from the CLI argument stream. When arg_spec.bulk is true, always returns an Array (normalized to [value] when non-bulk). For type: :identifier, an optional block provides the percent-selector lookup.

      Parameters:

      Yield Parameters:

      • field (String)

        field name from a percent-selector (%field:value)

      • value (String)

        value from a percent-selector

      Yield Returns:

      • (String)

        resolved identifier

      Returns:

      • (Object)

        the resolved value, or Array when arg_spec.bulk is true

      
      
      559
      560
      561
      562
      563
      564
      565
      566
      567
      568
      569
      570
      571
      572
      573
      574
      575
      576
      577
      578
      579
      580
      581
      582
      583
      584
      585
      586
      587
      588
      589
      590
      591
      592
      593
      594
      595
      596
      597
      598
      599
      # File 'lib/aspera/cli/plugins/base.rb', line 559
      
      def resolve_argument(arg_spec, &block)
        if arg_spec.bulk
          is_bulk = options.get_option(:bulk)
          if arg_spec.type.eql?(:identifier)
            val = options.instance_identifier(description: arg_spec.name.to_s, &block)
          else
            val = options.get_next_argument(
              arg_spec.name.to_s,
              mandatory: arg_spec.mandatory,
              validation: is_bulk ? Array : arg_spec.type,
              default:   arg_spec.default,
              schema:    arg_spec.schema
            )
            if is_bulk
              Aspera.assert_array_all(val, arg_spec.type, type: Cli::BadArgument) { 'type' } unless arg_spec.type.nil?
            end
          end
          # Always return an Array when bulk: true
          is_bulk ? val : [val]
        else
          case arg_spec.type
          when :identifier
            options.instance_identifier(description: arg_spec.name.to_s, &block)
          else
            # Class or Array<Class> -> pass as validation type
            # When interactive: true, set ask_missing_mandatory so that get_interactive is triggered
            # when no CLI arguments are provided (mandatory is forced to true for the same reason:
            # a non-nil default would short-circuit get_interactive before it is ever called).
            options.ask_missing_mandatory = true if arg_spec.interactive
            options.get_next_argument(
              arg_spec.name.to_s,
              mandatory:   arg_spec.interactive ? true : arg_spec.mandatory,
              multiple:    arg_spec.multiple || false,
              validation:  arg_spec.type,
              accept_list: arg_spec.allowed,
              default:     arg_spec.interactive ? nil : arg_spec.default,
              schema:      arg_spec.schema
            )
          end
        end
      end

      #transferAspera::Cli::TransferAgent

      
      
      346
      # File 'lib/aspera/cli/plugins/base.rb', line 346
      
      def transfer; @context.transfer; end