aspera-cli 4.26.2 → 4.27.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (136) hide show
  1. checksums.yaml +4 -4
  2. checksums.yaml.gz.sig +0 -0
  3. data/CHANGELOG.md +281 -21
  4. data/CONTRIBUTING.md +98 -7
  5. data/bin/ascli +3 -3
  6. data/docs/README.md +10757 -0
  7. data/docs/test-mcp-with-ai.md +202 -0
  8. data/lib/aspera/agent/base.rb +20 -6
  9. data/lib/aspera/agent/connect.rb +53 -16
  10. data/lib/aspera/agent/desktop.rb +52 -17
  11. data/lib/aspera/agent/direct.rb +26 -24
  12. data/lib/aspera/agent/httpgw.rb +1 -1
  13. data/lib/aspera/agent/node.rb +32 -0
  14. data/lib/aspera/agent/transferd.rb +39 -3
  15. data/lib/aspera/api/alee.rb +1 -1
  16. data/lib/aspera/api/aoc.rb +14 -16
  17. data/lib/aspera/api/cos_node.rb +1 -1
  18. data/lib/aspera/api/faspex.rb +15 -14
  19. data/lib/aspera/api/httpgw.rb +4 -4
  20. data/lib/aspera/api/node.rb +26 -23
  21. data/lib/aspera/api/queries/bss_subscription_account.graphql +18 -0
  22. data/lib/aspera/api/queries/bss_subscription_usage.graphql +18 -0
  23. data/lib/aspera/ascmd.rb +21 -19
  24. data/lib/aspera/ascp/installation.rb +37 -38
  25. data/lib/aspera/ascp/management.rb +2 -3
  26. data/lib/aspera/cli/ascp_actions.rb +155 -0
  27. data/lib/aspera/cli/async_transfer_store.rb +81 -0
  28. data/lib/aspera/cli/bootstrapper.rb +197 -0
  29. data/lib/aspera/cli/command_registry.rb +145 -0
  30. data/lib/aspera/cli/command_spec.rb +138 -0
  31. data/lib/aspera/cli/context.rb +16 -3
  32. data/lib/aspera/cli/error.rb +16 -0
  33. data/lib/aspera/cli/extended_value.rb +2 -1
  34. data/lib/aspera/cli/formatter.rb +41 -27
  35. data/lib/aspera/cli/gem_checker.rb +65 -0
  36. data/lib/aspera/cli/hints.rb +2 -2
  37. data/lib/aspera/cli/http.rb +70 -19
  38. data/lib/aspera/cli/info.rb +2 -0
  39. data/lib/aspera/cli/mailer.rb +97 -0
  40. data/lib/aspera/cli/mcp_tool.rb +198 -0
  41. data/lib/aspera/cli/options.schema.yaml +364 -7
  42. data/lib/aspera/cli/{manager.rb → parser.rb} +334 -142
  43. data/lib/aspera/cli/plugins/alee.rb +20 -22
  44. data/lib/aspera/cli/plugins/aoc.rb +1166 -853
  45. data/lib/aspera/cli/plugins/ats.rb +200 -161
  46. data/lib/aspera/cli/plugins/base.rb +457 -100
  47. data/lib/aspera/cli/plugins/basic_auth.rb +14 -4
  48. data/lib/aspera/cli/plugins/config.rb +428 -754
  49. data/lib/aspera/cli/plugins/console.rb +106 -64
  50. data/lib/aspera/cli/plugins/cos.rb +44 -32
  51. data/lib/aspera/cli/plugins/factory.rb +4 -4
  52. data/lib/aspera/cli/plugins/faspex.rb +294 -257
  53. data/lib/aspera/cli/plugins/faspex5.rb +585 -357
  54. data/lib/aspera/cli/plugins/faspio.rb +49 -51
  55. data/lib/aspera/cli/plugins/httpgw.rb +18 -25
  56. data/lib/aspera/cli/plugins/mcp.rb +279 -0
  57. data/lib/aspera/cli/plugins/node.rb +998 -794
  58. data/lib/aspera/cli/plugins/oauth.rb +7 -10
  59. data/lib/aspera/cli/plugins/orchestrator.rb +111 -134
  60. data/lib/aspera/cli/plugins/preview.rb +180 -139
  61. data/lib/aspera/cli/plugins/server.rb +124 -79
  62. data/lib/aspera/cli/plugins/shares.rb +301 -107
  63. data/lib/aspera/cli/preset_actions.rb +139 -0
  64. data/lib/aspera/cli/preset_manager.rb +13 -12
  65. data/lib/aspera/cli/result.rb +61 -11
  66. data/lib/aspera/cli/runner.rb +275 -141
  67. data/lib/aspera/cli/secret_finder.rb +40 -0
  68. data/lib/aspera/cli/special_values.rb +1 -0
  69. data/lib/aspera/cli/sync_actions.rb +83 -59
  70. data/lib/aspera/cli/terminal_formatter.rb +1 -1
  71. data/lib/aspera/cli/transfer_actions.rb +83 -0
  72. data/lib/aspera/cli/transfer_agent.rb +116 -51
  73. data/lib/aspera/cli/transfer_progress.rb +3 -3
  74. data/lib/aspera/cli/vault_manager.rb +57 -0
  75. data/lib/aspera/cli/version.rb +1 -1
  76. data/lib/aspera/cli/wizard.rb +21 -20
  77. data/lib/aspera/command_line_builder.rb +22 -22
  78. data/lib/aspera/data_repository.rb +2 -1
  79. data/lib/aspera/dot_container.rb +4 -4
  80. data/lib/aspera/environment.rb +18 -11
  81. data/lib/aspera/exec_spec.rb +13 -0
  82. data/lib/aspera/faspex_gw.rb +1 -1
  83. data/lib/aspera/faspex_postproc.rb +4 -3
  84. data/lib/aspera/graphql.rb +35 -0
  85. data/lib/aspera/hash_ext.rb +6 -0
  86. data/lib/aspera/json_rpc/client.rb +62 -0
  87. data/lib/aspera/json_rpc/version.rb +7 -0
  88. data/lib/aspera/keychain/encrypted_hash.rb +1 -1
  89. data/lib/aspera/keychain/factory.rb +3 -3
  90. data/lib/aspera/keychain/macos_security.rb +3 -3
  91. data/lib/aspera/link_header.rb +82 -0
  92. data/lib/aspera/log.rb +17 -3
  93. data/lib/aspera/markdown.rb +85 -2
  94. data/lib/aspera/node_simulator.rb +3 -1
  95. data/lib/aspera/oauth/base.rb +5 -5
  96. data/lib/aspera/oauth/boot.rb +2 -2
  97. data/lib/aspera/oauth/factory.rb +7 -5
  98. data/lib/aspera/oauth/jwt.rb +4 -4
  99. data/lib/aspera/oauth/web.rb +2 -2
  100. data/lib/aspera/persistency_action_once.rb +8 -8
  101. data/lib/aspera/persistency_folder.rb +10 -3
  102. data/lib/aspera/preview/file_types.rb +3 -3
  103. data/lib/aspera/preview/generator.rb +3 -3
  104. data/lib/aspera/preview/utils.rb +1 -1
  105. data/lib/aspera/products/connect.rb +1 -1
  106. data/lib/aspera/products/other.rb +1 -1
  107. data/lib/aspera/products/transferd.rb +1 -1
  108. data/lib/aspera/proxy_auto_config.rb +7 -6
  109. data/lib/aspera/rest.rb +10 -36
  110. data/lib/aspera/rest_error_analyzer.rb +3 -3
  111. data/lib/aspera/rest_errors_aspera.rb +0 -10
  112. data/lib/aspera/rest_list.rb +3 -3
  113. data/lib/aspera/schema/IBM Aspera on Cloud API-0.2.6-enhanced.yaml +1853 -510
  114. data/lib/aspera/schema/documentation.rb +57 -26
  115. data/lib/aspera/schema/reader.rb +59 -10
  116. data/lib/aspera/schema/registry.rb +39 -6
  117. data/lib/aspera/secret_hider.rb +7 -2
  118. data/lib/aspera/ssl.rb +4 -4
  119. data/lib/aspera/sync/conf.schema.yaml +2 -2
  120. data/lib/aspera/sync/operations.rb +10 -13
  121. data/lib/aspera/temp_file_manager.rb +1 -1
  122. data/lib/aspera/timer_limiter.rb +1 -1
  123. data/lib/aspera/transfer/faux_file.rb +24 -11
  124. data/lib/aspera/transfer/parameters.rb +23 -21
  125. data/lib/aspera/transfer/result.rb +74 -0
  126. data/lib/aspera/transfer/resumer.rb +6 -6
  127. data/lib/aspera/transfer/spec.rb +18 -1
  128. data/lib/aspera/transfer/spec.schema.yaml +12 -3
  129. data/lib/aspera/uri_reader.rb +2 -2
  130. data/lib/aspera/web_auth.rb +2 -2
  131. data/lib/aspera/web_server_simple.rb +8 -5
  132. data/lib/aspera/yaml.rb +2 -1
  133. data.tar.gz.sig +0 -0
  134. metadata +25 -3
  135. metadata.gz.sig +0 -0
  136. data/lib/aspera/json_rpc.rb +0 -52
@@ -4,6 +4,10 @@ require 'aspera/cli/special_values'
4
4
  require 'aspera/log'
5
5
  require 'aspera/assert'
6
6
  require 'aspera/line_logger'
7
+ require 'aspera/schema/registry'
8
+ require 'aspera/rest'
9
+ require 'aspera/oauth'
10
+ require 'aspera/ssl'
7
11
  require 'openssl'
8
12
 
9
13
  module Aspera
@@ -29,19 +33,64 @@ module Aspera
29
33
  @certificate_paths = nil
30
34
  end
31
35
 
32
- attr_accessor :insecure, :warn_insecure, :http_options
33
- attr_reader :ignore_cert_host_port
34
-
35
- # Declare all HTTP/S CLI options, with handlers pointing to self.
36
- # Called once from Config#initialize after this object is instantiated.
37
- # @param options [Aspera::Cli::Manager]
38
- def declare_options(options)
39
- options.declare(:insecure, 'HTTP/S: Do not validate any certificate', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :insecure}, default: false)
40
- options.declare(:ignore_certificate, 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass], handler: {o: self, m: :ignore_cert_host_port})
41
- options.declare(:warn_insecure, 'HTTP/S: Issue a warning if certificate is ignored', allowed: Allowed::TYPES_BOOLEAN, handler: {o: self, m: :warn_insecure}, default: true)
42
- options.declare(:cert_stores, 'HTTP/S: List of folder with trusted certificates', allowed: Allowed::TYPES_STRING_ARRAY, handler: {o: self, m: :trusted_cert_locations})
43
- options.declare(:http_options, 'HTTP/S: Options for HTTP/S socket', allowed: Hash, handler: {o: self, m: :http_options}, default: {})
44
- options.declare(:http_proxy, 'HTTP/S: URL for proxy with optional credentials', handler: {o: self, m: :http_proxy})
36
+ attr_accessor :insecure, :warn_insecure
37
+ attr_reader :ignore_cert_host_port, :http_options
38
+
39
+ class << self
40
+ # Declare all HTTP/S CLI options (metadata only - no handler binding yet).
41
+ # Called once from Config#initialize before this instance is available as a target.
42
+ # Handlers are bound in a second pass via bind_options once the instance exists.
43
+ # @param options [Aspera::Cli::Parser] CLI options manager to declare options into
44
+ # @return [nil]
45
+ def declare_options(options)
46
+ options.declare(:insecure, description: 'HTTP/S: Do not validate any certificate', allowed: Allowed::TYPES_BOOLEAN, default: false)
47
+ options.declare(:ignore_certificate, description: 'HTTP/S: Do not validate certificate for these URLs', allowed: [Array, NilClass])
48
+ options.declare(:warn_insecure, description: 'HTTP/S: Issue a warning if certificate is ignored', allowed: Allowed::TYPES_BOOLEAN, default: true)
49
+ options.declare(:cert_stores, description: 'HTTP/S: List of folder with trusted certificates', allowed: Allowed::TYPES_STRING_ARRAY)
50
+ options.declare(:http_options, schema: Schema::Registry::HTTP_OPTIONS)
51
+ options.declare(:http_proxy, description: 'HTTP/S: URL for proxy with optional credentials')
52
+ nil
53
+ end
54
+ end
55
+
56
+ # Bind all HTTP options to this instance using set_handler.
57
+ # Called from Config#initialize immediately after Http.new.
58
+ # @param options [Aspera::Cli::Parser]
59
+ # @return [nil]
60
+ def bind_options(options)
61
+ options.set_handler(:insecure, object: self, method: :insecure)
62
+ options.set_handler(:ignore_certificate, object: self, method: :ignore_cert_host_port)
63
+ options.set_handler(:warn_insecure, object: self, method: :warn_insecure)
64
+ options.set_handler(:cert_stores, object: self, method: :trusted_cert_locations)
65
+ options.set_handler(:http_options, object: self, method: :http_options)
66
+ options.set_handler(:http_proxy, object: self, method: :http_proxy)
67
+ end
68
+
69
+ # Setter for http_options: dispatch each key to its target singleton immediately.
70
+ # Keys matching RestParameters setters go to RestParameters, 'ssl_options' goes to SSL,
71
+ # keys matching OAuth::Factory.instance.parameters go to OAuth, and the rest are kept
72
+ # in @http_options for Net::HTTP session configuration in update_session.
73
+ # This runs on every assignment (JSON hash, dotted notation, preset merge) so timing
74
+ # of option parsing never matters.
75
+ # @param new_options [Hash] merged http_options hash
76
+ # @return [nil]
77
+ def http_options=(new_options)
78
+ Aspera.assert_type(new_options, Hash)
79
+ kept = {}
80
+ new_options.each do |k, v|
81
+ method = "#{k}=".to_sym
82
+ if RestParameters.instance.respond_to?(method)
83
+ RestParameters.instance.send(method, v)
84
+ elsif k.to_s.eql?('ssl_options')
85
+ Aspera::SSL.option_list = v
86
+ elsif OAuth::Factory.instance.parameters.key?(k.to_sym)
87
+ OAuth::Factory.instance.parameters[k.to_sym] = v
88
+ else
89
+ kept[k] = v
90
+ end
91
+ end
92
+ @http_options = kept
93
+ nil
45
94
  end
46
95
 
47
96
  # ------------------------------------------------------------------
@@ -64,7 +113,7 @@ module Aspera
64
113
  def ignore_cert_host_port=(url_list)
65
114
  url_list.each do |url|
66
115
  uri = URI.parse(url)
67
- raise "Expecting https scheme: #{url}" unless uri.scheme.eql?('https')
116
+ Aspera.assert(uri.scheme.eql?('https')){"Expecting https scheme: #{url}"}
68
117
  @ignore_cert_host_port.push([uri.host, uri.port].freeze)
69
118
  end
70
119
  end
@@ -90,7 +139,8 @@ module Aspera
90
139
  # ------------------------------------------------------------------
91
140
 
92
141
  # Add files, folders or the default OS locations to the cert store.
93
- # @param path_list [Array<String>]
142
+ # @param path_list [Array<String>] list of file/folder paths to add to the certificate store
143
+ # @return [nil]
94
144
  def trusted_cert_locations=(path_list)
95
145
  Aspera.assert_type(path_list, Array){'cert locations'}
96
146
  if @certificate_store.nil?
@@ -126,6 +176,7 @@ module Aspera
126
176
  end
127
177
  end
128
178
  @certificate_paths.uniq!
179
+ nil
129
180
  end
130
181
 
131
182
  # Return cert file paths (computes OS defaults lazily if never set).
@@ -145,12 +196,11 @@ module Aspera
145
196
  # Called every time a new Net::HTTP session is opened.
146
197
  # ------------------------------------------------------------------
147
198
 
148
- # @param http_session [Net::HTTP]
199
+ # @param http_session [Net::HTTP] HTTP session to configure
200
+ # @return [nil]
149
201
  def update_session(http_session)
150
202
  http_session.set_debug_output(LineLogger.new(:trace2)) if Log.instance.logger.trace2?
151
- if http_session.use_ssl? && ignore_cert?(http_session.address, http_session.port)
152
- http_session.verify_mode = SELF_SIGNED_CERT
153
- end
203
+ http_session.verify_mode = SELF_SIGNED_CERT if http_session.use_ssl? && ignore_cert?(http_session.address, http_session.port)
154
204
  http_session.cert_store = @certificate_store if @certificate_store
155
205
  Log.log.debug{"Using cert store #{http_session.cert_store} (#{@certificate_store})"} unless http_session.cert_store.nil?
156
206
  @http_options.each do |k, v|
@@ -161,6 +211,7 @@ module Aspera
161
211
  Log.log.error{"Unknown HTTP session attribute: #{k}"}
162
212
  end
163
213
  end
214
+ nil
164
215
  end
165
216
  end
166
217
  end
@@ -15,6 +15,8 @@ module Aspera
15
15
  # Set this to warn in advance when minimum required ruby version will increase
16
16
  # See also required_ruby_version in gemspec file
17
17
  RUBY_FUTURE_MINIMUM_VERSION = '3.2'
18
+ # Version with which this version of CLI was tested
19
+ SDK_VERSION = '1.1.9'
18
20
  end
19
21
  end
20
22
  end
@@ -0,0 +1,97 @@
1
+ # frozen_string_literal: true
2
+
3
+ require 'aspera/environment'
4
+ require 'aspera/log'
5
+ require 'aspera/assert'
6
+ require 'erb'
7
+
8
+ module Aspera
9
+ module Cli
10
+ # Email service injected into Context as :mailer so that any component
11
+ # (TransferAgent, plugins, ...) can send emails via SMTP and ERB templates.
12
+ # Provides :smtp, :notify_to, :notify_template options via the Options manager.
13
+ class Mailer
14
+ SMTP_CONF_PARAMS = %i[server tls ssl port domain username password from_name from_email].freeze
15
+ SMTP_BOOL_PARAMS = %i[tls ssl].freeze
16
+ SMTP_INT_PARAMS = %i[port].freeze
17
+ SMTP_STR_PARAMS = %i[server domain username password from_email from_name].freeze
18
+
19
+ # @param options [Parser] CLI options manager (provides :smtp, :notify_to, :notify_template)
20
+ # @param main_folder [String] application main folder (unused directly but kept for symmetry)
21
+ def initialize(options, main_folder)
22
+ @options = options
23
+ @main_folder = main_folder
24
+ end
25
+
26
+ # @return [Parser]
27
+ attr_reader :options
28
+
29
+ # @return [Hash] email server settings with defaults applied
30
+ def email_settings
31
+ smtp = options.get_option(:smtp, mandatory: true)
32
+ # Change keys from string into symbol
33
+ smtp = smtp.symbolize_keys
34
+ unsupported = smtp.keys - SMTP_CONF_PARAMS
35
+ Aspera.assert(unsupported.empty?, type: Cli::Error){"Unsupported SMTP parameter: #{unsupported.join(', ')}, use: #{SMTP_CONF_PARAMS.join(', ')}"}
36
+ # Boolean fields must be actual booleans, not strings like "false"
37
+ SMTP_BOOL_PARAMS.each do |k|
38
+ Aspera.assert_values(smtp[k], [true, false], type: Cli::Error){"smtp.#{k}"} if smtp.key?(k)
39
+ end
40
+ SMTP_INT_PARAMS.each do |k|
41
+ Aspera.assert_type(smtp[k], Integer, type: Cli::Error){"smtp.#{k}"} if smtp.key?(k)
42
+ end
43
+ SMTP_STR_PARAMS.each do |k|
44
+ Aspera.assert_type(smtp[k], String, type: Cli::Error){"smtp.#{k}"} if smtp.key?(k)
45
+ end
46
+ # smtp[:ssl] = nil (false)
47
+ smtp[:tls] = !smtp[:ssl] unless smtp.key?(:tls)
48
+ smtp[:port] ||= if smtp[:tls]
49
+ 587
50
+ elsif smtp[:ssl]
51
+ 465
52
+ else
53
+ 25
54
+ end
55
+ smtp[:from_email] ||= smtp[:username] if smtp.key?(:username)
56
+ smtp[:from_name] ||= smtp[:from_email].sub(/@.*$/, '').gsub(/[^a-zA-Z]/, ' ').capitalize if smtp.key?(:username)
57
+ smtp[:domain] ||= smtp[:from_email].sub(/^.*@/, '') if smtp.key?(:from_email)
58
+ %i[server port domain].each do |n|
59
+ Aspera.assert(smtp.key?(n)){"Missing mandatory smtp parameter: #{n}"}
60
+ end
61
+ Log.log.debug{"smtp=#{smtp}"}
62
+ return smtp
63
+ end
64
+
65
+ # Send email using ERB template
66
+ # @param email_template_default [String] default template, can be overridden by option
67
+ # @param values [Hash] values to be used in template, keys with default: to, from_name, from_email
68
+ def send_email_template(email_template_default: nil, values: {})
69
+ values[:to] ||= options.get_option(:notify_to, mandatory: true)
70
+ notify_template = options.get_option(:notify_template, mandatory: email_template_default.nil?) || email_template_default
71
+ mail_conf = email_settings
72
+ values[:from_name] ||= mail_conf[:from_name]
73
+ values[:from_email] ||= mail_conf[:from_email]
74
+ %i[to from_email].each do |n|
75
+ Aspera.assert_type(values[n], String){"Missing email parameter: #{n} in config"}
76
+ end
77
+ start_options = [mail_conf[:domain]]
78
+ start_options.push(mail_conf[:username], mail_conf[:password], :login) if mail_conf.key?(:username) && mail_conf.key?(:password)
79
+ template_binding = Environment.empty_binding
80
+ values.each do |k, v|
81
+ Aspera.assert_type(k, Symbol)
82
+ template_binding.local_variable_set(k, v)
83
+ end
84
+ msg_with_headers = ERB.new(notify_template).result(template_binding)
85
+ Log.dump(:msg_with_headers, msg_with_headers)
86
+ require 'net/smtp'
87
+ smtp = Net::SMTP.new(mail_conf[:server], mail_conf[:port])
88
+ smtp.enable_starttls if mail_conf[:tls]
89
+ smtp.enable_tls if mail_conf[:ssl]
90
+ smtp.start(*start_options) do |smtp_session|
91
+ smtp_session.send_message(msg_with_headers, values[:from_email], values[:to])
92
+ end
93
+ nil
94
+ end
95
+ end
96
+ end
97
+ end
@@ -0,0 +1,198 @@
1
+ # frozen_string_literal: true
2
+
3
+ # cspell:ignore ascli
4
+
5
+ require 'json'
6
+ require 'aspera/log'
7
+ require 'aspera/cli/runner'
8
+ require 'aspera/cli/error'
9
+ unless defined?(MCP::Tool)
10
+ begin
11
+ require 'mcp'
12
+ rescue LoadError
13
+ raise Cli::Error, "The 'mcp' gem is required. Install it with: gem install mcp"
14
+ end
15
+ end
16
+
17
+ module Aspera
18
+ module Cli
19
+ # MCP Tool: executes an ascli command in-process.
20
+ class McpTool < MCP::Tool
21
+ # Default maximum byte size of the JSON text content returned for list results.
22
+ # Items are appended whole until the limit is reached; the full list is always
23
+ # available in structuredContent.
24
+ DEFAULT_MAX_TEXT_BYTES = 100_000
25
+ # Default extra arguments automatically prepended to every ascli call.
26
+ # Keeps the AI from having to remember mandatory flags on every invocation.
27
+ DEFAULT_EXTRA_ARGS = ['--interactive=no', '--transfer.asynchronous=true'].freeze
28
+
29
+ tool_name 'execute_ascli_command'
30
+
31
+ description <<~DESC.strip
32
+ Execute any ascli (Aspera CLI) command in-process and return its result.
33
+
34
+ SYNTAX
35
+ args is a JSON array of strings mirroring the CLI command line.
36
+ Element 0 : plugin name (aoc, faspex5, node, server, config, …).
37
+ Elements 1+: sub-commands, then --option=value flags in any order.
38
+ Passing structured values: use an extended-value prefix on the relevant element:
39
+ "@json:{...}" — inline JSON object or array (use for Hash/Array arguments)
40
+ "@preset:name" — expand a saved credential preset
41
+ "@env:VAR" — read value from environment variable
42
+ "@file:/path" — read value from a file
43
+
44
+ AUTOMATIC FLAGS
45
+ The server automatically prepends extra_args to every call (default:
46
+ --interactive=no and --transfer.asynchronous=true).
47
+ Do NOT repeat these flags in your args array — they are already injected.
48
+ If you explicitly need to override one (e.g. --interactive=yes), include it
49
+ in your args; your value will take precedence because it appears after the
50
+ injected ones.
51
+ If credentials are missing or incomplete, the command will return an error;
52
+ report it and stop, never wait for input.
53
+
54
+ DISCOVERY — recommended sequence
55
+ Step 1 — enumerate all commands:
56
+ ["config", "commands"]
57
+ Returns { syntax, description } for every leaf command of every plugin.
58
+ Syntax notation: <arg> mandatory, [<arg>] optional, <a|b> enum, <arg...> variadic.
59
+ This single call covers all 800+ commands — no other discovery step is needed
60
+ unless you want details about a specific command or its Hash arguments.
61
+ IMPORTANT: never guess command names from training data. If you are unsure of
62
+ the exact subcommand name (e.g. shared_folders vs shared_inboxes), always call
63
+ ["config", "commands"] first to find the correct name.
64
+
65
+ Step 2 — inspect a Hash argument schema BEFORE calling any command with <data>:
66
+ MANDATORY: whenever the command syntax shows a <data> argument, you MUST call
67
+ "help" offline first. Never infer field names from server error messages.
68
+ ["<plugin>", "<cmd>", ..., "help"]
69
+ Replace the Hash positional argument with the literal string "help".
70
+ Returns a table of field names, types, and descriptions for that argument.
71
+ Example: ["aoc", "admin", "user", "create", "help"]
72
+ Note: only works for Hash-typed arguments, not for plain String arguments.
73
+
74
+ Step 2b — discover available query/filter parameters for list commands:
75
+ ["<plugin>", "<cmd>", ..., "--query=help"]
76
+ Add --query=help to any list command to see all supported filter parameters
77
+ with their types and descriptions.
78
+ Example: ["aoc", "admin", "user", "list", "--query=help"]
79
+ Example: ["faspex5", "admin", "packages", "list", "--query=help"]
80
+ Note: only works on commands that support --query filtering (list/delete).
81
+
82
+ Step 3 — list all options for a plugin as structured data:
83
+ ["config", "options", "<plugin>"]
84
+ Returns { option, description, allowed, deprecated } for every --flag
85
+ accepted by that plugin (global + plugin-specific, ~80 entries).
86
+ Use when you need to know the exact allowed values or find a specific flag.
87
+
88
+ Full documentation:
89
+ ["config", "documentation", "toc"]
90
+ Returns the table of contents: { level, title, anchor } for every heading.
91
+ ["config", "documentation", "local", "<anchor>"]
92
+ Returns only the section matching that anchor (same slugs as GitHub).
93
+ ["config", "documentation", "local", "--ui=text"]
94
+ Returns the complete README (~300 KB). Use only when a specific section
95
+ is insufficient and you need broader narrative context.
96
+
97
+ RESULT FORMAT
98
+ Structured data is always in structuredContent (a JSON object).
99
+ For list results, text content is limited to #{DEFAULT_MAX_TEXT_BYTES} bytes (whole items only).
100
+ When truncated, a WARNING block is appended: "WARNING: result truncated to N of TOTAL items."
101
+ You MUST read structuredContent.items to obtain the full dataset — never report
102
+ counts, totals, or search results from a truncated text block.
103
+
104
+ FILE LIST FOR TRANSFERS
105
+ For all transfers (upload, download, package send, …), append source file paths
106
+ at the end of the args array — no --sources flag needed.
107
+ ["server", "upload", "--to-folder=/dst", "/local/file1", "/local/file2"]
108
+ ["aoc", "packages", "send", "@:", "name=pkg", "recipients.0=user@example.com", "END",
109
+ "/local/file1", "/local/file2"]
110
+
111
+ EXAMPLES
112
+ ["config", "commands"] ← Step 1: full capability map
113
+ ["aoc", "admin", "user", "create", "help"] ← Step 2: schema of <data> Hash
114
+ ["config", "options", "aoc"] ← Step 3: all --flags for aoc plugin
115
+ ["config", "documentation", "toc"] ← TOC of local README
116
+ ["config", "documentation", "local",
117
+ "leveraging-ai-assistance"] ← single README section by anchor
118
+ ["aoc", "admin", "user", "create",
119
+ '@json:{"email":"a@b.com","name":"Alice"}',
120
+ "--url=https://org.ibmaspera.com", "--username=admin@org.com",
121
+ "--password=secret"]
122
+ ["server", "browse", "/",
123
+ "--url=https://host", "--username=user", "--password=secret"]
124
+ ["server", "upload", "--url=https://host", "--username=user", "--password=secret",
125
+ "--to-folder=/uploads", "/local/file1.txt", "/local/file2.txt"]
126
+ ["aoc", "packages", "list", "--workspace=MyWorkspace"]
127
+ ["node", "info", "--url=https://node-host",
128
+ "--username=user", "--password=pass"]
129
+ DESC
130
+
131
+ input_schema(
132
+ properties: {
133
+ args: {
134
+ type: 'array',
135
+ items: {type: 'string'},
136
+ minItems: 1,
137
+ description: 'ascli arguments: first element is the plugin name, followed by action and --option=value flags'
138
+ }
139
+ },
140
+ required: ['args']
141
+ )
142
+
143
+ class << self
144
+ attr_accessor :max_text_bytes, :extra_args
145
+
146
+ def call(args:, server_context: nil)
147
+ effective_args = Array(extra_args || DEFAULT_EXTRA_ARGS) + args
148
+ Log.dump(:mcp_execute, effective_args)
149
+ runner = Runner.new(effective_args)
150
+ result = runner.run_with_result
151
+ case result
152
+ when Result::Nothing, Result::Empty, NilClass
153
+ MCP::Tool::Response.new([{type: 'text', text: ''}])
154
+ when Result::SingleObject, Result::ObjectList, Result::ValueList
155
+ # Apply --select filter in place (affects both text and structuredContent).
156
+ runner.context.formatter.filter_columns_on_select(result.data) if result.data.is_a?(Array)
157
+ # MCP spec requires structuredContent to be a JSON object (not an array).
158
+ structured = result.data.is_a?(Array) ? {items: result.data} : result.data
159
+ content = if result.data.is_a?(Array)
160
+ text_limit = max_text_bytes || DEFAULT_MAX_TEXT_BYTES
161
+ truncated_items = truncate_items_by_bytes(result.data, text_limit)
162
+ total = result.data.size
163
+ if truncated_items.size < total
164
+ [
165
+ {type: 'text', text: JSON.generate(truncated_items)},
166
+ {type: 'text', text: "WARNING: result truncated to #{truncated_items.size} of #{total} items. Full dataset available in structuredContent."}
167
+ ]
168
+ else
169
+ [{type: 'text', text: JSON.generate(result.data)}]
170
+ end
171
+ else
172
+ [{type: 'text', text: JSON.generate(result.data)}]
173
+ end
174
+ MCP::Tool::Response.new(content, structured_content: structured)
175
+ else
176
+ MCP::Tool::Response.new([{type: 'text', text: result.data.to_s}])
177
+ end
178
+ rescue SystemExit => e
179
+ MCP::Tool::Response.new([{type: 'text', text: "exited with status #{e.status}"}], error: !e.status.zero?)
180
+ rescue => e
181
+ MCP::Tool::Response.new([{type: 'text', text: "#{e.class}: #{e.message}"}], error: true)
182
+ end
183
+
184
+ # Returns the largest prefix of +items+ whose JSON serialization fits within +max_bytes+.
185
+ # Items are appended whole — no item is ever split mid-JSON.
186
+ def truncate_items_by_bytes(items, max_bytes)
187
+ buf = +''
188
+ items.each_with_index do |item, i|
189
+ fragment = (i.zero? ? '[' : ',') + JSON.generate(item)
190
+ break if buf.bytesize + fragment.bytesize + 1 > max_bytes # +1 for closing ']'
191
+ buf << fragment
192
+ end
193
+ buf.empty? ? [] : JSON.parse("#{buf}]")
194
+ end
195
+ end
196
+ end
197
+ end
198
+ end