portage-cli 0.9.0 → 0.10.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.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 3d34c9ba25977baefa9f1f802e38ae6b2175e455d246f7a0dd3a2833136c46e8
4
- data.tar.gz: 2c1d701bafd9c914115bdb02ab661906eaddba139e0627a56bfa0cf094e71d06
3
+ metadata.gz: c506344f3b17a2ea4199f84efaf70d658b0debec2fe03d167b847c58617e800b
4
+ data.tar.gz: 20c00c246e59fa9059611f7bb54380140295eada2c5deb457a11ec94309c1b04
5
5
  SHA512:
6
- metadata.gz: ec3b6bf08d832abbe197591050b229f38a3650603feb6e94b47a6e84fbb9d3b9ddf531c51e84d71c8b708131b655e6b501e6a55662908eb6edb60981da4f14a2
7
- data.tar.gz: 137b718fd1283383aa70879a6f2290a35dc1bc3f3a898ceff6965019b110329caf943dcf3ef39c04c29d1661beee3da6edd3a056877b7a6c3a932debce90c99f
6
+ metadata.gz: 9a3eff64f57ab81774a2a52034232a4667227aaff13aa7279d26dd41cf52faeafeea691711755fb41fd2f3a0a67f77d144d248d48c2631779a49c10bec9f2882
7
+ data.tar.gz: ade0a2ba64ba928ec1272a8902858bad1f79897f5059d428528b8978e4045047ee1a0e6e2579dc2f8a468fa73b0c3491cc66c48f2bc1a18951600e05bd2a25e1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,18 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-09-29
10
+
11
+ - **`portage check <url> [--json]`.** Reports whether Portage can buy from a store and
12
+ how: `verdict` (`automated`, `webmcp`, `handoff`, `unsupported`), `next_step`, and the
13
+ detail behind them (native UCP, platform, adapter install and missing env, hand-off-only,
14
+ WebMCP status). Wraps `Portage::Ucp::Check` and follows the precedence `buy` uses.
15
+ Plain GETs only, hand-off-only hosts are not contacted, and WebMCP is read only from an
16
+ already-open Portage profile tab. Exits `0` for `automated` and `webmcp`. The hand-off-only
17
+ test moved into a shared `HandoffHost` so `buy` and `check` can't disagree.
18
+ Follows a homepage `<link rel="ucp">` manifest pointer through `portage-ucp` 0.11.0,
19
+ which this release now requires (`~> 0.11`).
20
+
9
21
  ## [0.9.0] - 2026-09-29
10
22
 
11
23
  - **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
data/README.md CHANGED
@@ -133,6 +133,7 @@ portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
133
133
  portage find --query "..." [--max-price N] [--limit N] [--json]
134
134
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
135
135
  [--max-price N] [--json]
136
+ portage check <url> [--json]
136
137
  portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
137
138
  [--choose REF | --compare REF | --view REF]
138
139
  portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
@@ -218,6 +219,18 @@ used (an unreadable value, an out-of-range threshold) stops the buy before
218
219
  anything runs: on stderr normally, or as a JSON report with `outcome:
219
220
  "invalid_option"` under `--json`.
220
221
 
222
+ ### Check
223
+
224
+ `portage check <url> [--json]` answers "can Portage buy from this store, and how?".
225
+ It checks for a native `/.well-known/ucp` manifest, detects the platform, notes
226
+ whether its adapter gem is installed and which env vars are missing, and looks for
227
+ WebMCP tools, using the same hand-off-only rules as `buy`. `verdict` is
228
+ `automated`, `webmcp`, `handoff` or `unsupported`, with a plain-English
229
+ `next_step`. Exits `0` for `automated` and `webmcp`. It sends plain GETs only, and
230
+ never contacts a hand-off-only host. WebMCP is read only from a tab your Portage
231
+ browser profile already has open on the store; it never launches a browser.
232
+ Takes the `--proxy*` flags.
233
+
221
234
  ### Compare
222
235
 
223
236
  `portage compare <url> --product-id ID` finds other stores selling the same
@@ -13,6 +13,7 @@ require_relative "checkout_handoff"
13
13
  require_relative "money"
14
14
  require_relative "notifier"
15
15
  require_relative "handoff_only"
16
+ require_relative "handoff_host"
16
17
  require_relative "offer_sources"
17
18
  require_relative "handoff_target"
18
19
  require_relative "handoff_agents"
@@ -189,32 +190,10 @@ module Portage
189
190
  # "handoff_only_hosts" is the user's own list (see HandoffOnly).
190
191
  def handoff_only?
191
192
  @handoff_only ||= HandoffOnly.new
192
- return true if @handoff_only.host?(@uri.host)
193
- return true if OfferSources.retail_handoff_host?(@uri.host)
194
-
195
- etsy_buyer_host?
196
- end
197
-
198
- # Phase 7 (docs/plans/buy-skill-and-local-browser.md):
199
- # portage-ucp-etsy is a *seller*-side adapter — a shop owner with
200
- # their own ETSY_* credentials set still reaches #adapter_flow
201
- # unchanged below, the same "only your own store" rule every other
202
- # adapter already gets (see the class comment at the top of this
203
- # file). An ordinary buyer with no Etsy credentials of their own
204
- # gets routed to hand-off here instead of a homepage fetch +
205
- # platform detection that would just be scraping a stranger's
206
- # listing — the buyer-side offer OfferSources::EtsyListings hands
207
- # `find` has nothing this process can check out anyway.
208
- def etsy_buyer_host?
209
- HandoffOnly.matches_any?(@uri.host, %w[etsy.com]) && !etsy_adapter_configured?
193
+ HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
210
194
  end
211
195
 
212
- def etsy_adapter_configured?
213
- platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
214
- return false unless platform
215
-
216
- Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
217
- end
196
+ def etsy_buyer_host? = HandoffHost.etsy_buyer_host?(@uri.host)
218
197
 
219
198
  # No checkout was ever built — there's nothing to browse or complete,
220
199
  # just a link and why. `checkout_url` is built, never fetched: the
@@ -0,0 +1,154 @@
1
+ require "uri"
2
+ require "portage/ucp"
3
+ require_relative "handoff_only"
4
+ require_relative "handoff_host"
5
+ require_relative "check_next_step"
6
+ require_relative "webmcp"
7
+ require_relative "browser_profile"
8
+
9
+ module Portage
10
+ module Cli
11
+ # `portage check <url>` — "can Portage buy from this store, and how?"
12
+ #
13
+ # Wraps Portage::Ucp::Check (native `/.well-known/ucp`, platform
14
+ # detection, live adapter probe) and adds what only the CLI knows, so
15
+ # the answer matches what `portage buy` would actually do: the
16
+ # hand-off-only list (HandoffHost, shared with Buy), whether the adapter
17
+ # gem is installed, and whether the page exposes WebMCP tools.
18
+ #
19
+ # Precedence mirrors Buy#call: hand-off-only host, then native UCP, then
20
+ # WebMCP, then a platform adapter, then nothing. A hand-off-only host
21
+ # short-circuits before any request is made.
22
+ #
23
+ # Read-only and quiet: plain GETs only (through Ucp::Check), never a
24
+ # cart, never a checkout. WebMCP needs a live page, and Portage never
25
+ # launches a browser for this: it only reads tools from a tab the
26
+ # Portage browser profile already has open on the store's host
27
+ # (`portage browser profile open --url ...`). With no such tab, or with
28
+ # portage-ucp-webmcp not installed, `webmcp.status` is "skipped" and says
29
+ # why. A `webmcp_bridge:` can be injected instead (specs, library use).
30
+ class Check
31
+ CART_CAP = "dev.ucp.shopping.cart".freeze
32
+ CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
33
+
34
+ # Verdicts `portage check` exits 0 for.
35
+ USABLE_VERDICTS = %w[automated webmcp].freeze
36
+
37
+ NO_TAB_REASON = "no Portage browser profile tab is open on this store " \
38
+ "(portage browser profile open --url URL), and check never launches a browser".freeze
39
+
40
+ def self.call(url, **) = new(url, **).call
41
+
42
+ # @param webmcp_bridge [#list_tools, nil] a bridge over the store's page.
43
+ # @param handoff_only [HandoffOnly]
44
+ # @param checker [#call] url -> Portage::Ucp::Check report (injectable).
45
+ def initialize(url, webmcp_bridge: nil, handoff_only: HandoffOnly.new, checker: Portage::Ucp::Check.method(:call))
46
+ raw = url.to_s.strip
47
+ @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
48
+ @webmcp_bridge = webmcp_bridge
49
+ @handoff_only = handoff_only
50
+ @checker = checker
51
+ end
52
+
53
+ def call
54
+ return handoff_only_report if HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
55
+
56
+ report = @checker.call(@uri.to_s).merge(handoff_only: false)
57
+ report = report.merge(adapter: adapter_for(report))
58
+ report = report.merge(webmcp: webmcp_for(report))
59
+ verdict = verdict_for(report)
60
+ report.merge(verdict: verdict, next_step: CheckNextStep.call(verdict, report))
61
+ end
62
+
63
+ private
64
+
65
+ def handoff_only_report
66
+ { url: @uri.to_s, native_ucp: nil, platform: nil, recommended_gem: nil, handoff_only: true, adapter: nil,
67
+ webmcp: webmcp_skipped("#{@uri.host} is hand-off only, so it isn't probed"),
68
+ verdict: "handoff",
69
+ next_step: "#{@uri.host} restricts automated purchasing agents. Portage opens the page and you buy." }
70
+ end
71
+
72
+ # --- adapter ---
73
+
74
+ def adapter_for(report)
75
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == report[:platform] }
76
+ return nil unless platform
77
+
78
+ { gem: platform.gem, installed: gem_installed?(platform),
79
+ missing_env: Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)) }
80
+ end
81
+
82
+ # Same test the live probe uses: the adapter's require_path either
83
+ # loads or it doesn't. Loading has no I/O.
84
+ def gem_installed?(platform)
85
+ require platform.require_path
86
+ true
87
+ rescue LoadError
88
+ false
89
+ end
90
+
91
+ # --- webmcp ---
92
+
93
+ def webmcp_for(report)
94
+ return webmcp_skipped("the store speaks UCP natively") if report[:native_ucp]
95
+ return webmcp_skipped("portage-ucp-webmcp isn't installed") unless Webmcp.available?
96
+
97
+ bridge = @webmcp_bridge || existing_tab_bridge
98
+ return webmcp_skipped(NO_TAB_REASON) unless bridge
99
+
100
+ detect_webmcp(bridge)
101
+ rescue StandardError => e
102
+ { status: "error", tools: [], reason: "#{e.class}: #{e.message}" }
103
+ end
104
+
105
+ def webmcp_skipped(reason) = { status: "skipped", tools: [], reason: reason }
106
+
107
+ def detect_webmcp(bridge)
108
+ tools = bridge.list_tools
109
+ names = tools.map { |tool| (tool["name"] || tool[:name]).to_s }
110
+ return { status: "none", tools: [], reason: "the page registers no WebMCP tools" } if names.empty?
111
+
112
+ preset = Portage::Ucp::WebMcp::Presets.detect(tools)
113
+ session = Portage::Ucp::WebMcp.connect(bridge: bridge, preset: preset)
114
+ return { status: "available", tools: names, reason: nil } if cart_and_checkout?(session)
115
+
116
+ { status: "none", tools: names,
117
+ reason: "the page's WebMCP tools aren't a cart and checkout set Portage recognises" }
118
+ end
119
+
120
+ def cart_and_checkout?(session) = session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
121
+
122
+ # Only ever a tab that is already open on the store's host: no new tab
123
+ # is created, no browser is started, nothing is navigated.
124
+ def existing_tab_bridge
125
+ profile = BrowserProfile::Profile.new
126
+ return nil unless profile.status[:running]
127
+
128
+ tab = BrowserProfile::Cdp.list(port: profile.port).find { |t| t["type"] == "page" && same_host?(t["url"]) }
129
+ ws_url = tab && tab["webSocketDebuggerUrl"]
130
+ return nil unless ws_url
131
+
132
+ BrowserProfile::Bridge.new(socket: BrowserProfile::CdpSocket.connect(ws_url),
133
+ allowlist: BrowserProfile::Allowlist.new(hosts: [@uri.host]))
134
+ end
135
+
136
+ def same_host?(url)
137
+ URI(url.to_s).host == @uri.host
138
+ rescue URI::InvalidURIError
139
+ false
140
+ end
141
+
142
+ # --- verdict ---
143
+
144
+ def verdict_for(report)
145
+ return "automated" if report[:native_ucp]
146
+ return "webmcp" if report.dig(:webmcp, :status) == "available"
147
+ return "automated" if report.dig(:live_probe, :status) == "ok"
148
+ return "handoff" if report[:platform]
149
+
150
+ "unsupported"
151
+ end
152
+ end
153
+ end
154
+ end
@@ -0,0 +1,51 @@
1
+ module Portage
2
+ module Cli
3
+ # The plain-English `next_step` of a Check report, one sentence per
4
+ # verdict — kept apart from Check so its detection logic stays readable.
5
+ module CheckNextStep
6
+ def self.call(verdict, report)
7
+ case verdict
8
+ when "automated" then automated(report)
9
+ when "webmcp" then "Portage will build the cart; you pay in your browser."
10
+ when "handoff" then adapter(report)
11
+ else unsupported(report)
12
+ end
13
+ end
14
+
15
+ def self.automated(report)
16
+ return "Nothing to do — this store speaks UCP natively." if report[:native_ucp]
17
+
18
+ "Nothing more to set up — the #{report[:platform]} adapter (#{report.dig(:adapter, :gem)}) is installed " \
19
+ "and answered a live probe."
20
+ end
21
+
22
+ def self.adapter(report)
23
+ adapter = report[:adapter]
24
+ return "#{report[:url]} restricts automated purchasing agents. Portage opens the page and you buy." unless
25
+ adapter
26
+
27
+ actions = []
28
+ actions << "install #{adapter[:gem]}" unless adapter[:installed]
29
+ actions << "set #{adapter[:missing_env].join(', ')}" if adapter[:missing_env].any?
30
+ return probe_failed(report) if actions.empty?
31
+
32
+ "To automate this #{report[:platform]} store (adapters act as the store's owner), " \
33
+ "#{actions.join(' and ')}. Until then Portage opens the store and you buy."
34
+ end
35
+
36
+ def self.probe_failed(report)
37
+ reason = report.dig(:live_probe, :reason)
38
+ "The #{report[:platform]} adapter is set up but its live probe failed#{" (#{reason})" if reason}. " \
39
+ "Run `portage doctor`; until then Portage opens the store and you buy."
40
+ end
41
+
42
+ def self.unsupported(report)
43
+ webmcp = report[:webmcp]
44
+ note = webmcp[:status] == "skipped" ? " (WebMCP not checked: #{webmcp[:reason]})" : ""
45
+ "No UCP manifest, known platform or WebMCP cart found#{note}. Portage can only open the store " \
46
+ "for you to browse."
47
+ end
48
+ private_class_method :automated, :adapter, :probe_failed, :unsupported
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,33 @@
1
+ require_relative "handoff_only"
2
+ require_relative "offer_sources"
3
+
4
+ module Portage
5
+ module Cli
6
+ # The one answer to "is this host hand-off only?" that both `buy` and
7
+ # `check` give, so `portage check` never says something `portage buy`
8
+ # wouldn't do. Three sources, in the order Buy has always consulted them:
9
+ # the user's own list (HandoffOnly, Amazon by default), the built-in
10
+ # retail hand-off hosts (Walmart, eBay, Best Buy), and Etsy for an
11
+ # ordinary buyer — portage-ucp-etsy is a seller-side adapter, so a shop
12
+ # owner with their own ETSY_* credentials set is not hand-off only.
13
+ module HandoffHost
14
+ def self.restricted?(host, handoff_only: HandoffOnly.new)
15
+ return true if handoff_only.host?(host)
16
+ return true if OfferSources.retail_handoff_host?(host)
17
+
18
+ etsy_buyer_host?(host)
19
+ end
20
+
21
+ def self.etsy_buyer_host?(host)
22
+ HandoffOnly.matches_any?(host, %w[etsy.com]) && !etsy_adapter_configured?
23
+ end
24
+
25
+ def self.etsy_adapter_configured?
26
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
27
+ return false unless platform
28
+
29
+ Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
30
+ end
31
+ end
32
+ end
33
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.9.0".freeze
3
+ VERSION = "0.10.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -15,6 +15,7 @@ require_relative "cli/browser_profile"
15
15
  require_relative "cli/buy"
16
16
  require_relative "cli/find"
17
17
  require_relative "cli/compare"
18
+ require_relative "cli/check"
18
19
  require_relative "cli/history"
19
20
  require_relative "cli/quotes"
20
21
  require_relative "cli/money"
@@ -56,6 +57,7 @@ module Portage
56
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
57
58
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
58
59
  [--max-price N] [--json]
60
+ portage check <url> [--json]
59
61
  portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
60
62
  [--choose REF | --compare REF | --view REF]
61
63
  portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
@@ -93,13 +95,13 @@ module Portage
93
95
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
94
96
  portage --version
95
97
 
96
- proxy flags (buy/find/compare/doctor/payment enroll):
98
+ proxy flags (buy/find/compare/check/doctor/payment enroll):
97
99
  [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
98
100
  [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
99
101
  [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
100
102
  USAGE
101
103
 
102
- COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
104
+ COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare, "check" => :run_check,
103
105
  "pick" => :run_pick, "approve" => :run_approve,
104
106
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
105
107
  "orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
@@ -207,6 +209,53 @@ module Portage
207
209
  def self.to_minor_units(major) = (major * 100).round
208
210
  private_class_method :to_minor_units
209
211
 
212
+ # --- check ---
213
+
214
+ # `portage check <url>` — can Portage buy from this store, and how?
215
+ # Exit 0 for `automated`/`webmcp`, 1 for anything else (and for a bad
216
+ # or missing URL, with the usage on stderr).
217
+ def self.run_check(argv)
218
+ opts = parse_check_options(argv)
219
+ return 1 unless opts
220
+ return 1 unless apply_proxy_settings(opts[:proxy])
221
+
222
+ report = Check.new(opts[:url]).call
223
+ puts opts[:json] ? JSON.pretty_generate(report) : format_check(report)
224
+ Check::USABLE_VERDICTS.include?(report[:verdict]) ? 0 : 1
225
+ end
226
+ private_class_method :run_check
227
+
228
+ def self.parse_check_options(argv)
229
+ opts = { proxy: {} }
230
+ OptionParser.new do |parser|
231
+ parser.on("--json") { opts[:json] = true }
232
+ ProxySettings.add_options(parser, opts[:proxy])
233
+ end.parse!(argv)
234
+ opts[:url] = argv.shift.to_s.strip
235
+ return opts if !opts[:url].empty? && argv.empty? && valid_check_url?(opts[:url])
236
+
237
+ warn USAGE
238
+ nil
239
+ end
240
+ private_class_method :parse_check_options
241
+
242
+ def self.valid_check_url?(url)
243
+ uri = URI.parse(url =~ %r{\Ahttps?://}i ? url : "https://#{url}")
244
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
245
+ rescue URI::InvalidURIError
246
+ false
247
+ end
248
+ private_class_method :valid_check_url?
249
+
250
+ def self.format_check(report)
251
+ lines = ["#{report[:url]}: #{report[:verdict]}"]
252
+ lines << " platform: #{report[:platform]}" if report[:platform]
253
+ lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
254
+ lines << " next: #{report[:next_step]}"
255
+ lines.join("\n")
256
+ end
257
+ private_class_method :format_check
258
+
210
259
  # --- compare ---
211
260
 
212
261
  def self.run_compare(argv)
metadata CHANGED
@@ -1,7 +1,7 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.9.0
4
+ version: 0.10.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.10'
33
+ version: '0.11'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.10'
40
+ version: '0.11'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: portage-ucp-client
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -184,6 +184,8 @@ files:
184
184
  - lib/portage/cli/buy.rb
185
185
  - lib/portage/cli/buyer_context.rb
186
186
  - lib/portage/cli/catalog_products.rb
187
+ - lib/portage/cli/check.rb
188
+ - lib/portage/cli/check_next_step.rb
187
189
  - lib/portage/cli/checkout_handoff.rb
188
190
  - lib/portage/cli/classifier.rb
189
191
  - lib/portage/cli/compare.rb
@@ -197,6 +199,7 @@ files:
197
199
  - lib/portage/cli/generate/adapter.rb
198
200
  - lib/portage/cli/generate/agent_profile.rb
199
201
  - lib/portage/cli/handoff_agents.rb
202
+ - lib/portage/cli/handoff_host.rb
200
203
  - lib/portage/cli/handoff_only.rb
201
204
  - lib/portage/cli/handoff_reconciler.rb
202
205
  - lib/portage/cli/handoff_reconciler/wire_adapters.rb