agent-harness 0.5.7 → 0.5.8

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.
@@ -16,12 +16,71 @@ module AgentHarness
16
16
  # end
17
17
  # end
18
18
  module Adapter
19
+ def self.normalize_metadata_installation(contract, provider_name:, binary_name:)
20
+ return nil unless contract.is_a?(Hash)
21
+
22
+ source = contract[:source]
23
+ install_command = contract[:install_command]&.dup
24
+
25
+ {
26
+ provider: provider_name.to_sym,
27
+ source_type: normalize_metadata_source_type(contract[:source_type] || source),
28
+ package_name: metadata_package_name(contract, source),
29
+ default_version: contract[:default_version] || contract[:version] || contract[:resolved_version],
30
+ resolved_version: contract[:resolved_version] || contract[:version] || contract[:default_version],
31
+ supported_version_requirement: normalize_metadata_version_requirement(
32
+ contract[:supported_version_requirement] || contract[:version_requirement]
33
+ ),
34
+ binary_name: contract[:binary_name] || binary_name,
35
+ install_command: install_command,
36
+ install_command_string: contract[:install_command_string] || install_command&.join(" ")
37
+ }
38
+ end
39
+
40
+ def self.normalize_metadata_source_type(source)
41
+ return source[:type]&.to_sym if source.is_a?(Hash)
42
+
43
+ source&.to_sym
44
+ end
45
+
46
+ def self.metadata_package_name(contract, source)
47
+ return contract[:package_name] if contract[:package_name]
48
+ return source[:package] if source.is_a?(Hash)
49
+
50
+ package = contract[:package]
51
+ return package unless package.is_a?(String)
52
+
53
+ if package.split("@").first == ""
54
+ package.split("@", 3).first(2).join("@")
55
+ else
56
+ package.split("@", 2).first
57
+ end
58
+ end
59
+
60
+ def self.normalize_metadata_version_requirement(requirement)
61
+ case requirement
62
+ when nil
63
+ nil
64
+ when Array
65
+ if requirement.all? { |entry| entry.is_a?(Array) && entry.length == 2 }
66
+ requirement.map { |operator, version| "#{operator} #{version}" }.join(", ")
67
+ else
68
+ requirement.join(", ")
69
+ end
70
+ else
71
+ requirement.to_s
72
+ end
73
+ end
74
+
19
75
  def self.included(base)
20
76
  base.extend(ClassMethods)
21
77
  end
22
78
 
23
79
  # Class methods that all providers must implement
24
80
  module ClassMethods
81
+ SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS = %i[anthropic claude].freeze
82
+ IMMUTABLE_METADATA_OVERRIDE_KEYS = %i[provider canonical_provider aliases binary_name].freeze
83
+
25
84
  # Human-readable provider name
26
85
  #
27
86
  # @return [Symbol] unique identifier for this provider
@@ -75,37 +134,521 @@ module AgentHarness
75
134
  []
76
135
  end
77
136
 
78
- # Installation contract for this provider's CLI.
137
+ # First-class install metadata for the provider CLI.
138
+ #
139
+ # Downstream applications can use this metadata to build provider
140
+ # images without hardcoding provider-specific install URLs, expected
141
+ # binary paths, or supported install targets.
142
+ #
143
+ # This is separate from .installation_contract, which serves
144
+ # package-driven CLIs. Providers that need richer install metadata
145
+ # (e.g. shell-script installers, checksums, artifact URLs) should
146
+ # override this method.
147
+ #
148
+ # @param version [String, Symbol, nil] optional install target/version
149
+ # @return [Hash, nil] provider install metadata, or nil when the
150
+ # provider does not expose a first-class install contract
151
+ def install_metadata(version: nil)
152
+ nil
153
+ end
154
+
155
+ # Installation contract for package-driven provider CLIs.
79
156
  #
80
157
  # Downstream apps can use this metadata to provision the provider CLI
81
158
  # without hardcoding package names, versions, or binary expectations
82
159
  # outside agent-harness.
83
160
  #
84
- # @return [Hash, nil] install metadata, or nil when no first-class
161
+ # @return [Hash, nil] install metadata, or nil when no package-based
85
162
  # installation contract is defined for the provider
86
163
  def installation_contract(**options)
87
164
  return install_contract unless options.key?(:version)
88
165
 
89
- install_contract(version: options[:version])
166
+ # Check if install_contract accepts the version: keyword before
167
+ # forwarding it; legacy providers may override install_contract
168
+ # without that parameter, which would raise ArgumentError.
169
+ params = method(:install_contract).parameters
170
+ accepts_version = params.any? do |type, name|
171
+ # Only treat an explicit `version:` keyword as proof the method
172
+ # handles version selection. A bare `**options` keyrest does not
173
+ # count — providers may add it for forward-compatibility without
174
+ # actually acting on the version value.
175
+ [:key, :keyreq].include?(type) && name == :version
176
+ end
177
+
178
+ if accepts_version
179
+ install_contract(version: options[:version])
180
+ else
181
+ install_contract
182
+ end
183
+ end
184
+
185
+ # Stable provider metadata for downstream configuration and policy UIs.
186
+ #
187
+ # This contract consolidates provider identifier aliases, auth/runtime
188
+ # details, installability, and health-check characteristics so apps do
189
+ # not need to maintain their own partial mirrors of adapter behavior.
190
+ #
191
+ # @param aliases [Array<Symbol, String>] alternate identifiers registered
192
+ # for this provider
193
+ # @param requested_name [Symbol, String] provider identifier originally
194
+ # requested by the caller; used to prefer alias-keyed config when
195
+ # metadata construction is config-sensitive
196
+ # @param canonical_name [Symbol, String] canonical registry identifier
197
+ # for this provider; used for the public stable metadata contract
198
+ # @return [Hash] provider metadata
199
+ def provider_metadata(aliases: [], refresh: false, requested_name: provider_name, canonical_name: provider_name)
200
+ normalized_aliases = normalize_metadata_aliases(aliases, canonical_name: canonical_name)
201
+ requested_provider_name = requested_name.to_sym
202
+ canonical_provider_name = canonical_name.to_sym
203
+ provider = metadata_provider_instance(
204
+ requested_name: requested_provider_name,
205
+ canonical_name: canonical_provider_name
206
+ )
207
+ configuration = deep_merge_metadata(
208
+ default_configuration_schema,
209
+ provider_metadata_hash(provider, :configuration_schema, default: {})
210
+ )
211
+ execution = deep_merge_metadata(
212
+ default_execution_semantics,
213
+ provider_metadata_hash(provider, :execution_semantics, default: {})
214
+ )
215
+ installation = Adapter.normalize_metadata_installation(
216
+ installation_contract,
217
+ provider_name: canonical_provider_name,
218
+ binary_name: binary_name
219
+ )
220
+ supported_auth_modes = Array(configuration[:auth_modes]).map(&:to_sym)
221
+ supports_registry_checks = !provider.nil?
222
+ auth_check_supported = auth_status_available?(
223
+ provider,
224
+ requested_name: requested_provider_name,
225
+ canonical_name: canonical_provider_name,
226
+ refresh: refresh
227
+ )
228
+ provider_status_check = supports_registry_checks && overrides_instance_method?(:health_status)
229
+ configuration_validation = supports_registry_checks && overrides_instance_method?(:validate_config)
230
+ lightweight_checks = supports_registry_checks && !provider_status_check && !configuration_validation
231
+
232
+ metadata = {
233
+ provider: canonical_provider_name,
234
+ canonical_provider: canonical_provider_name,
235
+ aliases: normalized_aliases,
236
+ display_name: provider_display_name(provider, canonical_name: canonical_provider_name),
237
+ binary_name: binary_name,
238
+ auth: {
239
+ default_mode: metadata_default_auth_mode(provider, supported_modes: supported_auth_modes),
240
+ supported_modes: supported_auth_modes,
241
+ service: nil,
242
+ api_family: nil
243
+ },
244
+ runtime: {
245
+ interface: :cli,
246
+ requires_cli: true,
247
+ available: metadata_runtime_available(refresh: refresh),
248
+ installable: !installation.nil?,
249
+ installation: installation,
250
+ prompt_delivery: execution[:prompt_delivery],
251
+ output_format: execution[:output_format],
252
+ sandbox_aware: execution[:sandbox_aware],
253
+ uses_subcommand: execution[:uses_subcommand],
254
+ supports_mcp: provider_metadata_value(provider, :supports_mcp?, default: default_supports_mcp),
255
+ supported_mcp_transports: provider_metadata_value(
256
+ provider,
257
+ :supported_mcp_transports,
258
+ default: default_supported_mcp_transports
259
+ ),
260
+ supports_sessions: provider_metadata_value(
261
+ provider,
262
+ :supports_sessions?,
263
+ default: default_supports_sessions
264
+ ),
265
+ supports_dangerous_mode: provider_metadata_value(
266
+ provider,
267
+ :supports_dangerous_mode?,
268
+ default: default_supports_dangerous_mode
269
+ )
270
+ },
271
+ configuration: configuration,
272
+ capabilities: deep_merge_metadata(
273
+ default_capabilities,
274
+ provider_metadata_hash(provider, :capabilities, default: {})
275
+ ),
276
+ health_check: {
277
+ supports_registry_checks: supports_registry_checks,
278
+ auth_check_supported: auth_check_supported,
279
+ provider_status: provider_status_check,
280
+ configuration_validation: configuration_validation,
281
+ lightweight: lightweight_checks
282
+ },
283
+ identity: {
284
+ bot_usernames: provider_bot_usernames(
285
+ canonical_name: canonical_provider_name,
286
+ aliases: normalized_aliases
287
+ )
288
+ }
289
+ }
290
+
291
+ deep_merge_metadata(metadata, sanitized_provider_metadata_overrides)
292
+ end
293
+
294
+ # Optional provider-specific metadata overrides for provider_metadata.
295
+ #
296
+ # @return [Hash]
297
+ def provider_metadata_overrides
298
+ {}
299
+ end
300
+
301
+ private
302
+
303
+ def normalize_metadata_aliases(aliases, canonical_name: provider_name)
304
+ canonical_provider_name = canonical_name.to_sym
305
+
306
+ Array(aliases)
307
+ .filter_map do |alias_name|
308
+ normalized_alias = alias_name.to_s.strip
309
+ next if normalized_alias.empty?
310
+
311
+ normalized_alias.to_sym
312
+ end
313
+ .uniq
314
+ .reject { |alias_name| alias_name == canonical_provider_name }
315
+ end
316
+
317
+ def provider_bot_usernames(canonical_name: provider_name, aliases: [])
318
+ [canonical_name, *aliases]
319
+ .filter_map do |identity|
320
+ normalized_identity = identity.to_s.strip
321
+ normalized_identity unless normalized_identity.empty?
322
+ end
323
+ .uniq
324
+ end
325
+
326
+ def metadata_provider_instance(requested_name: provider_name, canonical_name: provider_name)
327
+ build_provider_instance(
328
+ config: metadata_provider_config(requested_name, canonical_name: canonical_name),
329
+ executor: AgentHarness.configuration.command_executor,
330
+ logger: AgentHarness.logger
331
+ )
332
+ rescue => e
333
+ AgentHarness.logger&.debug(
334
+ "[AgentHarness::Providers::Adapter] Falling back to default metadata for #{provider_name}: #{e.class}"
335
+ )
336
+ nil
337
+ end
338
+
339
+ def safe_metadata_provider_instance(requested_name: provider_name, canonical_name: provider_name)
340
+ build_provider_instance(
341
+ config: metadata_provider_config(requested_name, canonical_name: canonical_name),
342
+ executor: AgentHarness.configuration.command_executor,
343
+ logger: AgentHarness.logger
344
+ )
345
+ rescue
346
+ # Return nil without logging - caller is responsible for handling
347
+ nil
348
+ end
349
+
350
+ def build_provider_instance(config: nil, executor: nil, logger: nil)
351
+ kwargs = provider_instance_kwargs(config: config, executor: executor, logger: logger)
352
+
353
+ if metadata_initializer_compatible? && !legacy_positional_initializer?
354
+ new(**kwargs)
355
+ else
356
+ # Preserve backwards compatibility with legacy positional constructors
357
+ # that accept arguments via a splat (e.g. initialize(*args)).
358
+ new(config: config, executor: executor, logger: logger)
359
+ end
360
+ rescue ArgumentError => e
361
+ # Only retry for signature-mismatch errors (wrong number/type of
362
+ # arguments), not for ArgumentError raised by real validation inside
363
+ # the initializer, which would duplicate side effects or expensive
364
+ # setup on the second call.
365
+ raise unless e.message.match?(/wrong number of arguments|unknown keyword|missing keyword/i)
366
+
367
+ new(config: config, executor: executor, logger: logger)
368
+ end
369
+
370
+ def provider_instance_kwargs(config: nil, executor: nil, logger: nil)
371
+ parameters = instance_method(:initialize).parameters
372
+ accepts = lambda do |name|
373
+ parameters.any? { |type, param_name| [:key, :keyreq].include?(type) && param_name == name } ||
374
+ parameters.any? { |type, _| type == :keyrest }
375
+ end
376
+
377
+ kwargs = {}
378
+ kwargs[:config] = config if accepts.call(:config)
379
+ kwargs[:executor] = executor if accepts.call(:executor)
380
+ kwargs[:logger] = logger if accepts.call(:logger)
381
+
382
+ kwargs
383
+ end
384
+
385
+ def legacy_positional_initializer?
386
+ instance_method(:initialize).parameters.any? do |type, _|
387
+ # Splat (*args) or optional positional (e.g. config = nil) params
388
+ # indicate a legacy constructor that should receive the full
389
+ # config:/executor:/logger: keyword set directly.
390
+ type == :rest || type == :opt
391
+ end
392
+ end
393
+
394
+ def metadata_provider_config(requested_name, canonical_name: provider_name)
395
+ requested_provider_name = requested_name.to_sym
396
+ canonical_provider_name = canonical_name.to_sym
397
+
398
+ AgentHarness.configuration.providers[requested_provider_name] ||
399
+ AgentHarness.configuration.providers[canonical_provider_name] ||
400
+ AgentHarness::ProviderConfig.new(requested_provider_name)
401
+ end
402
+
403
+ def metadata_initializer_compatible?
404
+ required_keywords = initializer_required_keywords
405
+ return false if instance_method(:initialize).parameters.any? { |type, _name| type == :req }
406
+
407
+ (required_keywords - supported_initializer_keywords).empty?
408
+ end
409
+
410
+ # Check if this provider has auth_status support available for health checks
411
+ #
412
+ # This differs from supports_registry_checks - it specifically indicates whether
413
+ # the auth status check will succeed or return "not implemented"
414
+ def auth_status_available?(
415
+ provider_instance = nil,
416
+ requested_name: provider_name,
417
+ canonical_name: provider_name,
418
+ refresh: false
419
+ )
420
+ @auth_status_available = {} unless instance_variable_defined?(:@auth_status_available)
421
+ cache_key = [requested_name.to_sym, canonical_name.to_sym]
422
+ return @auth_status_available[cache_key] if !refresh && @auth_status_available.key?(cache_key)
423
+
424
+ @auth_status_available[cache_key] = begin
425
+ provider_instance ||= safe_metadata_provider_instance(
426
+ requested_name: requested_name,
427
+ canonical_name: canonical_name
428
+ )
429
+ auth_status_supported_by?(
430
+ provider_instance,
431
+ requested_name: requested_name,
432
+ canonical_name: canonical_name
433
+ )
434
+ rescue
435
+ false
436
+ end
437
+ end
438
+
439
+ def auth_status_supported_by?(provider_instance, requested_name: provider_name, canonical_name: provider_name)
440
+ return false unless provider_instance
441
+
442
+ if provider_instance.respond_to?(:auth_status) &&
443
+ provider_instance.method(:auth_status).owner != AgentHarness::Providers::Adapter
444
+ return true
445
+ end
446
+
447
+ return false unless provider_instance.respond_to?(:auth_type)
448
+
449
+ case provider_instance.auth_type
450
+ when :api_key
451
+ false
452
+ when :oauth
453
+ provider_class_name = if provider_instance.class.respond_to?(:provider_name)
454
+ provider_instance.class.provider_name.to_sym
455
+ end
456
+
457
+ return false unless SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS.include?(provider_class_name)
458
+
459
+ [requested_name, canonical_name]
460
+ .map(&:to_sym)
461
+ .any? { |name| SUPPORTED_OAUTH_AUTH_STATUS_PROVIDERS.include?(name) }
462
+ else
463
+ false
464
+ end
465
+ end
466
+
467
+ def initializer_required_keywords
468
+ parameters = instance_method(:initialize).parameters
469
+
470
+ parameters.filter_map { |type, name| name if type == :keyreq }
471
+ end
472
+
473
+ def supported_initializer_keywords
474
+ %i[config executor logger]
475
+ end
476
+
477
+ def metadata_runtime_available(refresh: false)
478
+ if refresh || !instance_variable_defined?(:@metadata_runtime_available)
479
+ @metadata_runtime_available = available?
480
+ end
481
+
482
+ @metadata_runtime_available
483
+ end
484
+
485
+ def overrides_instance_method?(method_name)
486
+ instance_method(method_name).owner != AgentHarness::Providers::Adapter
487
+ end
488
+
489
+ def deep_merge_metadata(base, overrides)
490
+ return base unless overrides.is_a?(Hash)
491
+
492
+ base.merge(overrides) do |_key, left, right|
493
+ if left.is_a?(Hash) && right.is_a?(Hash)
494
+ deep_merge_metadata(left, right)
495
+ else
496
+ right
497
+ end
498
+ end
499
+ end
500
+
501
+ def sanitized_provider_metadata_overrides
502
+ overrides = provider_metadata_overrides
503
+ return {} unless overrides.is_a?(Hash)
504
+
505
+ overrides.each_with_object({}) do |(key, value), sanitized|
506
+ next if immutable_metadata_override_key?(key)
507
+
508
+ sanitized[key] = value
509
+ end
510
+ end
511
+
512
+ def immutable_metadata_override_key?(key)
513
+ IMMUTABLE_METADATA_OVERRIDE_KEYS.include?(key.to_sym)
514
+ rescue NoMethodError
515
+ false
516
+ end
517
+
518
+ def provider_metadata_hash(provider, method_name, default:)
519
+ value = provider_metadata_value(provider, method_name, default: default)
520
+ value.is_a?(Hash) ? value : default
521
+ end
522
+
523
+ def provider_metadata_value(provider, method_name, default:)
524
+ return default unless provider
525
+
526
+ provider.public_send(method_name)
527
+ rescue => e
528
+ AgentHarness.logger&.debug(
529
+ "[AgentHarness::Providers::Adapter] Falling back to default #{method_name} metadata for #{provider_name}: #{e.class}"
530
+ )
531
+ default
532
+ end
533
+
534
+ def provider_display_name(provider, canonical_name: provider_name)
535
+ if provider&.respond_to?(:display_name) &&
536
+ provider.method(:display_name).owner != AgentHarness::Providers::Base
537
+ return provider_metadata_value(
538
+ provider,
539
+ :display_name,
540
+ default: canonical_name.to_s.split("_").map(&:capitalize).join(" ")
541
+ )
542
+ end
543
+
544
+ canonical_name.to_s.split("_").map(&:capitalize).join(" ")
545
+ end
546
+
547
+ def metadata_default_auth_mode(provider, supported_modes:)
548
+ provider_auth_type = provider_metadata_value(provider, :auth_type, default: nil)&.to_sym
549
+ return provider_auth_type if provider_auth_type && supported_modes.include?(provider_auth_type)
550
+ return supported_modes.first unless supported_modes.empty?
551
+
552
+ provider_auth_type
553
+ end
554
+
555
+ def default_configuration_schema
556
+ {
557
+ fields: [],
558
+ auth_modes: [default_auth_type],
559
+ openai_compatible: false
560
+ }
561
+ end
562
+
563
+ def default_execution_semantics
564
+ {
565
+ prompt_delivery: :arg,
566
+ output_format: :text,
567
+ sandbox_aware: false,
568
+ uses_subcommand: false,
569
+ non_interactive_flag: nil,
570
+ legitimate_exit_codes: [0],
571
+ stderr_is_diagnostic: true,
572
+ parses_rate_limit_reset: false
573
+ }
574
+ end
575
+
576
+ def default_auth_type
577
+ :api_key
578
+ end
579
+
580
+ def default_capabilities
581
+ {
582
+ streaming: false,
583
+ file_upload: false,
584
+ vision: false,
585
+ tool_use: false,
586
+ json_mode: false,
587
+ mcp: false,
588
+ dangerous_mode: false
589
+ }
590
+ end
591
+
592
+ def default_supports_mcp
593
+ false
594
+ end
595
+
596
+ def default_supported_mcp_transports
597
+ []
598
+ end
599
+
600
+ def default_supports_sessions
601
+ false
602
+ end
603
+
604
+ def default_supports_dangerous_mode
605
+ false
90
606
  end
91
607
 
92
- # Build the install command from the provider installation contract.
608
+ public
609
+
610
+ # Shell command for installing the provider CLI.
93
611
  #
94
- # @param version [String, nil] optional explicit version override
95
- # @return [Array<String>, nil] install command argv or nil when the
96
- # provider has no install contract
612
+ # @param version [String, Symbol, nil] optional install target/version
613
+ # @return [String, Array<String>, nil] shell command or argv, or nil
614
+ # when the provider does not expose an install contract
97
615
  def install_command(version: nil)
616
+ metadata = install_metadata(version: version)
617
+ command = metadata&.dig(:source, :command)
618
+ return command if command
619
+
98
620
  contract = installation_contract
99
621
  return nil unless contract
100
622
 
101
623
  return contract[:install_command] unless version
102
624
 
625
+ versioned_contract = versioned_installation_contract(version)
626
+ if versioned_contract&.key?(:install_command)
627
+ return versioned_contract[:install_command]
628
+ end
629
+
103
630
  package_name = contract[:package_name]
104
631
  unless package_name
105
632
  raise ArgumentError, "installation_contract must define :package_name when overriding version"
106
633
  end
107
634
 
108
- Array(contract[:install_command_prefix]) + ["#{package_name}@#{version}"]
635
+ requirement = contract[:version_requirement]
636
+ if requirement
637
+ requirement_args = Array(requirement).map do |entry|
638
+ entry.is_a?(Array) ? "#{entry[0]} #{entry[1]}" : entry
639
+ end
640
+ parsed_requirement = Gem::Requirement.new(*requirement_args)
641
+ unless parsed_requirement.satisfied_by?(Gem::Version.new(version))
642
+ raise ArgumentError,
643
+ "Unsupported #{provider_name} CLI version #{version.inspect}; " \
644
+ "supported versions must satisfy #{parsed_requirement}"
645
+ end
646
+ end
647
+
648
+ version_format = contract.fetch(:version_format, "%{package_name}@%{version}")
649
+ package_with_version = format(version_format, package_name: package_name, version: version)
650
+
651
+ Array(contract[:install_command_prefix]) + [package_with_version]
109
652
  end
110
653
 
111
654
  # Canonical smoke-test contract for this provider.
@@ -117,6 +660,44 @@ module AgentHarness
117
660
  def smoke_test_contract
118
661
  nil
119
662
  end
663
+
664
+ private
665
+
666
+ def versioned_installation_contract(version)
667
+ # Only reuse the provider's own contract when the provider actually
668
+ # implements version-aware logic. We require an explicit `version:`
669
+ # keyword parameter — a bare `**options` keyrest is NOT sufficient
670
+ # because providers may add it for forward-compatibility without
671
+ # actually acting on the version value.
672
+ #
673
+ # For providers that override installation_contract directly we
674
+ # inspect that method. When the default (keyrest) implementation is
675
+ # in use, the version support depends on install_contract, so we
676
+ # inspect that instead.
677
+
678
+ ic_params = method(:installation_contract).parameters
679
+ ic_accepts_version = ic_params.any? do |type, name|
680
+ [:key, :keyreq].include?(type) && name == :version
681
+ end
682
+
683
+ # The default implementation in Adapter::ClassMethods uses **options
684
+ # and delegates to install_contract, so version support depends on
685
+ # install_contract's signature. Only fall through when the default is
686
+ # actually in use — a provider that overrides installation_contract
687
+ # with its own version: keyword (even combined with **options) should
688
+ # be trusted directly.
689
+ default_owner = Adapter::ClassMethods
690
+ if method(:installation_contract).owner == default_owner
691
+ ic_params = method(:install_contract).parameters
692
+ ic_accepts_version = ic_params.any? do |type, name|
693
+ [:key, :keyreq].include?(type) && name == :version
694
+ end
695
+ end
696
+
697
+ return unless ic_accepts_version
698
+
699
+ installation_contract(version: version)
700
+ end
120
701
  end
121
702
 
122
703
  # Instance methods