portage-cli 0.7.5 → 0.9.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. metadata +67 -2
@@ -4,12 +4,18 @@ require "json"
4
4
  require "portage/ucp"
5
5
  require "portage/ucp/client"
6
6
  require "portage/ucp/journal"
7
+ require_relative "agent_profile_url"
7
8
  require_relative "payment_methods"
8
9
  require_relative "setting"
9
10
  require_relative "decisions"
10
11
  require_relative "confidence_check"
11
12
  require_relative "checkout_handoff"
13
+ require_relative "money"
12
14
  require_relative "notifier"
15
+ require_relative "handoff_only"
16
+ require_relative "offer_sources"
17
+ require_relative "handoff_target"
18
+ require_relative "handoff_agents"
13
19
  require_relative "user_agent"
14
20
  require_relative "homepage_fetch"
15
21
  require_relative "permissive_authenticator"
@@ -17,6 +23,11 @@ require_relative "handoff_reconciler"
17
23
  require_relative "handoff_spend_mode"
18
24
  require_relative "webmcp"
19
25
  require_relative "webmcp_checkout_mode"
26
+ require_relative "webmcp_mappings"
27
+ require_relative "webmcp_mapping_confirm"
28
+ require_relative "webmcp_autofill_mode"
29
+ require_relative "webmcp_autofill_fields"
30
+ require_relative "webmcp_autofill_confirm"
20
31
 
21
32
  module Portage
22
33
  module Cli
@@ -47,6 +58,13 @@ module Portage
47
58
  # webhook URL a dead-end checkout_url is POSTed to — nil (the
48
59
  # default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
49
60
  # Notifier).
61
+ # @param handoff_target [HandoffTarget, nil] docs/plans/
62
+ # buy-skill-and-local-browser.md Phase 5 — which of default/print/
63
+ # profile/agent:<name> a hand-off dispatches to. nil (the default)
64
+ # builds one from PORTAGE_HANDOFF_TARGET/config.json with no
65
+ # per-invocation override — `Cli.run_buy` builds and validates one
66
+ # from `--handoff-target` up front instead, the same posture as
67
+ # `confidence_check:`.
50
68
  # @param confidence_check [ConfidenceCheck, nil] the opt-in confidence
51
69
  # gate in front of an unattended completion. nil (the default) builds
52
70
  # one from PORTAGE_DECISION_BACKEND / PORTAGE_MIN_CONFIDENCE, which is
@@ -63,17 +81,54 @@ module Portage
63
81
  # @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
64
82
  # handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
65
83
  # Bridge (already pointed at a navigated page) an embedding caller
66
- # already holds. nil (the default, and the only option from the
67
- # `portage buy` CLI, which has no browser of its own) skips WebMCP
68
- # entirely — zero behavior change from before this parameter
69
- # existed. Given one, attempted after native-UCP discovery finds
70
- # nothing at this URL and before a platform-adapter fallback (see
71
- # #webmcp_flow).
72
- # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus injectable collaborators
84
+ # already holds. nil (the default) skips WebMCP entirely — zero
85
+ # behavior change from before this parameter existed. Given one,
86
+ # attempted after native-UCP discovery finds nothing at this URL
87
+ # and before a platform-adapter fallback (see #webmcp_flow).
88
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: `portage buy`
89
+ # from the shell now builds one itself — a
90
+ # Portage::Cli::BrowserProfile::Bridge onto the Portage browser
91
+ # profile — whenever `--handoff-target profile` resolves and that
92
+ # profile is running (`Cli.profile_webmcp_bridge`); any other
93
+ # target still leaves this nil, the CLI's original "no browser of
94
+ # its own" posture.
95
+ # @param webmcp_mappings [Portage::Cli::WebmcpMappings, nil] Phase 2's
96
+ # confirmed-mapping store (docs/plans/webmcp-universal-outbound.md).
97
+ # nil (the default) builds the real `~/.portage/webmcp_mappings.json`
98
+ # -backed one lazily, only if #webmcp_matched_session ever runs —
99
+ # injectable so specs don't touch it.
100
+ # @param webmcp_mapping_confirm [Portage::Cli::WebmcpMappingConfirm, nil]
101
+ # Phase 2's confirm-before-mutate gate. nil (the default) builds one
102
+ # from whether stdin is a real TTY and `json:` is off — injectable so
103
+ # a spec can simulate an interactive "y" without a real terminal.
104
+ # @param json [Boolean] whether this run is `--json` — Phase 2's
105
+ # mapping confirmation prompt never fires under it (same posture as
106
+ # "no TTY"), since a caller reading structured stdout has nowhere to
107
+ # put an interactive prompt.
108
+ # @param autofill [Boolean, nil] docs/plans/webmcp-universal-outbound.md
109
+ # Phase 3 — `--autofill`'s value: true when the flag was passed, nil
110
+ # (the default) otherwise. Only ever turns WebmcpAutofillMode on;
111
+ # `PORTAGE_WEBMCP_AUTOFILL=approve`/config.json still work with this
112
+ # left nil. Approving the mode is still only half of Phase 3's
113
+ # opt-in — see #webmcp_autofill_confirm.
114
+ # @param webmcp_autofill_confirm [Portage::Cli::WebmcpAutofillConfirm, nil]
115
+ # Phase 3's per-run "fill exactly these fields?" prompt. nil (the
116
+ # default) builds one from the same interactive? posture as Phase
117
+ # 2's webmcp_mapping_confirm; injectable so a spec can simulate an
118
+ # interactive "y" without a real terminal.
119
+ # @param quote_total [Integer, nil] minor units — set by `buy --quote`:
120
+ # the total the person was quoted. Every path that would charge or
121
+ # hand off the real checkout first checks its total against this
122
+ # (with `quote_currency:`) and reports `quote_changed` instead if it
123
+ # is higher, in another currency, or missing. See #finish_checkout.
124
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
125
+ # injectable collaborators, each assigned to its own ivar
73
126
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
74
- auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil,
75
- max_price: nil, webmcp_bridge: nil)
76
- # rubocop:enable Metrics/ParameterLists
127
+ auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
128
+ transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
129
+ webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
130
+ quote_total: nil, quote_currency: nil)
131
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
77
132
  raw = url.to_s.strip
78
133
  @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
79
134
  @query = query
@@ -84,9 +139,17 @@ module Portage
84
139
  @product_id = product_id
85
140
  @auto_open = auto_open
86
141
  @notify_webhook = notify_webhook
142
+ @handoff_target = handoff_target
87
143
  @confidence_check = confidence_check
88
144
  @transaction_log = transaction_log
89
145
  @max_price = max_price
146
+ @webmcp_mappings = webmcp_mappings
147
+ @webmcp_mapping_confirm = webmcp_mapping_confirm
148
+ @autofill = autofill
149
+ @webmcp_autofill_confirm = webmcp_autofill_confirm
150
+ @json = json
151
+ @quote_total = quote_total
152
+ @quote_currency = quote_currency
90
153
  @webmcp_bridge = webmcp_bridge
91
154
  @decisions = {}
92
155
  end
@@ -95,6 +158,8 @@ module Portage
95
158
  POLICY_LINK_TYPES = /policy|policies|terms|contact|privacy|legal|imprint/i
96
159
 
97
160
  def call
161
+ return handoff_only_report if handoff_only?
162
+
98
163
  session = discover(@uri)
99
164
  return native_flow(session) if session
100
165
 
@@ -115,6 +180,90 @@ module Portage
115
180
 
116
181
  private
117
182
 
183
+ # --- Step 0: hand-off-only hosts (Tier C) ---
184
+
185
+ # Checked before anything else in #call so a hand-off-only host never
186
+ # sees a single request from this process — no UCP probe, no
187
+ # homepage fetch, no cart (docs/plans/buy-skill-and-local-browser.md
188
+ # Phase 5, decision 5). Amazon by default; ~/.portage/config.json's
189
+ # "handoff_only_hosts" is the user's own list (see HandoffOnly).
190
+ def handoff_only?
191
+ @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?
210
+ end
211
+
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
218
+
219
+ # No checkout was ever built — there's nothing to browse or complete,
220
+ # just a link and why. `checkout_url` is built, never fetched: the
221
+ # product page/cart-add URL when a product id is known (Amazon's own
222
+ # URL shape only — that's the only pattern this phase knows), or the
223
+ # retailer's own search URL for the query, or (a user-added host with
224
+ # no known pattern) the origin's homepage.
225
+ def handoff_only_report
226
+ url = handoff_only_checkout_url
227
+ handoff = @dry_run ? nil : dispatch_handoff_only(url)
228
+ build_report(source: "handoff_only", outcome: "handoff_only", browse: false, checkout: false,
229
+ checkout_url: url, legal_notice: HandoffOnly::LEGAL_NOTICE, handoff: handoff,
230
+ message: "#{@uri.host} restricts automated purchasing agents — Portage opens the page " \
231
+ "and you buy.")
232
+ end
233
+
234
+ def dispatch_handoff_only(url)
235
+ payload = handoff_notify_payload({}, url, reason: "handoff_only", source: "handoff_only",
236
+ message: "Hand-off-only retailer — no automated checkout " \
237
+ "was attempted.", warnings: [], over_cap: false)
238
+ finalize_handoff(url, dispatch_to_target(url, payload), notified: false, notify_error: nil)
239
+ end
240
+
241
+ def handoff_only_checkout_url
242
+ return amazon_checkout_url if HandoffOnly.amazon?(@uri.host)
243
+ return @uri.to_s if OfferSources.retail_handoff_host?(@uri.host) || etsy_buyer_host?
244
+
245
+ origin_homepage
246
+ end
247
+
248
+ def amazon_checkout_url
249
+ return amazon_cart_add_url if @product_id
250
+ return amazon_search_url unless @query.to_s.strip.empty?
251
+
252
+ origin_homepage
253
+ end
254
+
255
+ def amazon_search_url
256
+ "#{origin}/s?k=#{URI.encode_www_form_component(@query.to_s)}"
257
+ end
258
+
259
+ def amazon_cart_add_url
260
+ "#{origin}/gp/aws/cart/add.html?ASIN.1=#{URI.encode_www_form_component(@product_id.to_s)}&Quantity.1=#{@qty}"
261
+ end
262
+
263
+ def origin_homepage = "#{origin}/"
264
+
265
+ def origin = "#{@uri.scheme}://#{@uri.host}"
266
+
118
267
  # --- Step 1: native UCP manifest ---
119
268
 
120
269
  def discover(url)
@@ -242,22 +391,267 @@ module Portage
242
391
  # That hand-off feeds Phases 1-3 completely unchanged: reason
243
392
  # `express_stop` reserves a pending record, notifies, and later
244
393
  # reconciles exactly like any other hand-off.
394
+ #
395
+ # `preset_name` is resolved once here (docs/plans/
396
+ # webmcp-universal-outbound.md Phase 1, decision 1) and handed to
397
+ # `connect` explicitly (a nil/Symbol either way skips its own
398
+ # detection) so the same answer also tells this method whether the
399
+ # page's checkout capability came from a real `create_checkout` tool
400
+ # or only from a preset's hand-off-only one — see
401
+ # #webmcp_handoff_checkout_flow.
402
+ #
403
+ # When a plain connect (no preset, no tool_names:) doesn't advertise
404
+ # cart+checkout, and no preset matched, #webmcp_matched_session (Phase
405
+ # 2) is the fallback: a confirmed mapping from a prior run, or a fresh
406
+ # one proposed by `Matcher` and confirmed here. A page that already
407
+ # speaks either a preset's names or the plain UCP action names never
408
+ # reaches it at all — Transport's own tool_names:/bare-action
409
+ # resolution already covers both, with nothing to confirm.
245
410
  def webmcp_flow
246
411
  return nil unless @webmcp_bridge
247
412
  return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
248
413
 
249
- session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
250
- return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
414
+ preset_name = webmcp_preset_name
415
+ session, unresolved = webmcp_session(preset_name)
416
+ return unresolved if unresolved
417
+ return nil unless session
251
418
 
252
419
  return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
253
420
 
254
- full_buy(session, source: "webmcp", force_handoff: true)
421
+ run_webmcp_checkout(session, preset_name)
255
422
  rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
256
423
  Portage::Ucp::Client::ServerError => e
424
+ # A Portage::Cli::BrowserProfile::DomainNotAllowedError (Phase 6's
425
+ # domain allowlist, tripped inside Bridge#evaluate) always surfaces
426
+ # here as a BridgeError — Bridges::ScriptEvaluator#evaluate wraps
427
+ # whatever its injected evaluate: callable raises, StandardError
428
+ # included, so there's no separate rescue clause for it.
257
429
  build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
258
430
  message: "WebMCP checkout failed: #{e.message}")
259
431
  end
260
432
 
433
+ # Split out of #webmcp_flow just to keep its own branching (bridge
434
+ # given?, gem installed?, cart/checkout capable?, token mode?) from
435
+ # also carrying the Phase 2 fallback fork (Metrics/CyclomaticComplexity)
436
+ # — same reasoning as #run_webmcp_checkout's own split.
437
+ #
438
+ # @return [Array(Session, nil)] a cart/checkout-capable session.
439
+ # @return [Array(nil, nil)] no cart/checkout capability anywhere —
440
+ # #webmcp_flow falls through to adapter detection, same as before
441
+ # Phase 2 existed.
442
+ # @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report.
443
+ def webmcp_session(preset_name)
444
+ session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: preset_name)
445
+ return [session, nil] if webmcp_cart_and_checkout?(session)
446
+ return [nil, nil] if preset_name
447
+
448
+ matched, unresolved = webmcp_matched_session
449
+ return [nil, unresolved] if unresolved
450
+ return [nil, nil] unless webmcp_cart_and_checkout?(matched)
451
+
452
+ [matched, nil]
453
+ end
454
+
455
+ def webmcp_cart_and_checkout?(session)
456
+ session&.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
457
+ end
458
+
459
+ # Phase 2 fallback (docs/plans/webmcp-universal-outbound.md): a
460
+ # previously confirmed mapping for this exact tool fingerprint is
461
+ # reused with no prompt (decision 3); otherwise `Matcher.propose`
462
+ # builds one and #webmcp_mapping_confirm decides whether it can be
463
+ # used — reads unconditionally, mutating actions only once approved.
464
+ #
465
+ # @return [Array(Session, nil)] a session built with the resolved
466
+ # tool_names:, when there was anything to resolve.
467
+ # @return [Array(nil, nil)] nothing usable — the page has no tools a
468
+ # candidate scored high enough against; #webmcp_flow's own
469
+ # post-connect capability check reports it same as any other
470
+ # cart/checkout-incapable page.
471
+ # @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report, when
472
+ # a mutating action was proposed but couldn't be confirmed.
473
+ def webmcp_matched_session
474
+ tools = @webmcp_bridge.list_tools
475
+ tool_names = webmcp_mappings.lookup(tools)
476
+
477
+ if tool_names.nil?
478
+ proposal = Portage::Ucp::WebMcp::Matcher.propose(tools)
479
+ return [nil, nil] if proposal.empty?
480
+
481
+ tool_names = webmcp_mapping_confirm.call(proposal, tools)
482
+ return [nil, webmcp_mapping_unconfirmed_report(proposal)] if tool_names.nil?
483
+
484
+ webmcp_mappings.confirm!(tools, tool_names: tool_names, origin: @uri.host)
485
+ end
486
+
487
+ [Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: nil, tool_names: tool_names), nil]
488
+ end
489
+
490
+ def webmcp_mappings
491
+ @webmcp_mappings ||= Portage::Cli::WebmcpMappings.load
492
+ end
493
+
494
+ def webmcp_mapping_confirm
495
+ @webmcp_mapping_confirm ||= Portage::Cli::WebmcpMappingConfirm.new(interactive: !@json && $stdin.tty?)
496
+ end
497
+
498
+ def webmcp_mapping_unconfirmed_report(proposal)
499
+ build_report(
500
+ source: "webmcp", outcome: "webmcp_mapping_unconfirmed", browse: false, checkout: false,
501
+ message: "This page's WebMCP tools don't match a known preset. Here's the proposed mapping — " \
502
+ "confirm it and retry, passing it back as tool_names:, or run this interactively (a real " \
503
+ "TTY, no --json) to confirm it now.",
504
+ tool_names_proposal: webmcp_serialize_proposal(proposal)
505
+ )
506
+ end
507
+
508
+ def webmcp_serialize_proposal(proposal)
509
+ proposal.transform_values do |match|
510
+ { tool_name: match.tool, confidence: match.confidence, reason: match.reason }
511
+ end
512
+ end
513
+
514
+ def webmcp_preset_name
515
+ Portage::Ucp::WebMcp::Presets.detect(@webmcp_bridge.list_tools)
516
+ end
517
+
518
+ # Split out of #webmcp_flow just to keep that method's own branching
519
+ # (bridge given?, gem installed?, cart/checkout capable?, token mode?)
520
+ # from also carrying this preset-shaped fork (Metrics/CyclomaticComplexity).
521
+ def run_webmcp_checkout(session, preset_name)
522
+ preset = preset_name && Portage::Ucp::WebMcp::Presets.fetch(preset_name)
523
+ return webmcp_handoff_checkout_flow(session, preset) if preset&.handoff_checkout
524
+
525
+ full_buy(session, source: "webmcp", force_handoff: true)
526
+ end
527
+
528
+ # A preset whose fingerprint has no `create_checkout` tool at all —
529
+ # Shopify's is the only one so far (see Presets::SHOPIFY) — can't run
530
+ # #full_buy's `session.create_checkout`; there's nothing on the page to
531
+ # call it against. Builds a cart, reads it back through the (already
532
+ # cart/checkout-capable, per the gate in #webmcp_flow) session so
533
+ # #reconcile_checkout has a `get_cart`-shaped document to check against
534
+ # (there's no checkout document either), then calls the preset's
535
+ # `handoff_checkout` tool directly on the bridge — after the cart
536
+ # read-back, not before, so any post-mutation "page not ready" gap
537
+ # (see Transport's own retry) has already been waited out by then.
538
+ #
539
+ # The checkout URL comes from whatever the hand-off tool itself
540
+ # returns, or — Shopify's `proceed_to_checkout` is a navigation, not
541
+ # data, and returns nothing url-shaped (README "Shopify storefronts")
542
+ # — from the bridge's own `#location` once the tab has navigated
543
+ # there, when the bridge offers one (Bridges::ScriptEvaluator does).
544
+ #
545
+ # Once the tab has navigated to that checkout page, this is also
546
+ # where Phase 3's autofill runs (#attempt_webmcp_autofill) — the bridge's
547
+ # browser is sitting on the store's own checkout, cart already built,
548
+ # which is exactly the moment (and the only flow) autofill needs: a
549
+ # real checkout DOM, not a UCP checkout wire object #full_buy never
550
+ # navigates a browser to at all.
551
+ def webmcp_handoff_checkout_flow(session, preset)
552
+ products = safe_search(session)
553
+ product = select_product(products)
554
+ unless product
555
+ return build_report(source: "webmcp", outcome: "no_match", browse: true, checkout: true,
556
+ products: products, message: no_match_message)
557
+ end
558
+ return webmcp_handoff_dry_run_report(products, product, preset) if @dry_run
559
+
560
+ created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
561
+ context: buyer_context, meta: agent_meta)
562
+ cart = session.get_cart(cart_id: created["id"], meta: agent_meta)
563
+ warnings = reconcile_checkout(product, cart)
564
+
565
+ result = @webmcp_bridge.execute_tool(preset.handoff_checkout, {})
566
+ autofill = attempt_webmcp_autofill(preset)
567
+ webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), warnings,
568
+ autofill: autofill)
569
+ end
570
+
571
+ # Unlike #full_buy's dry run (which still creates a UCP checkout, since
572
+ # that's a document the store drops on its own), a dry run here stops
573
+ # before `create_cart`: every step after it changes something outside
574
+ # this process — a real cart on the store, the bridge's own tab
575
+ # navigated to checkout, and (with autofill on) typing into that page.
576
+ # None of that is safe to do on a preview run, so this only reports
577
+ # what the run would have done. No checkout_id either, so History
578
+ # records it as a search, not a purchase.
579
+ def webmcp_handoff_dry_run_report(products, product, preset)
580
+ build_report(
581
+ source: "webmcp", outcome: "dry_run", browse: true, checkout: true, products: products, handoff: nil,
582
+ decisions: @decisions.dup,
583
+ would: { line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
584
+ handoff_checkout: preset.handoff_checkout, autofill: webmcp_autofill_approved? },
585
+ message: "Dry run — would add #{product['title'] || line_item_id_of(product)} to the store's cart and " \
586
+ "hand off through #{preset.handoff_checkout}. Nothing was added, and no checkout was opened."
587
+ )
588
+ end
589
+
590
+ # Best-effort: the hand-off tool's own result shape isn't documented
591
+ # anywhere in this repo (see Presets::SHOPIFY's comment on
592
+ # `update_cart_lines`/`proceed_to_checkout`), so this only recognizes
593
+ # the shapes any WebMCP tool result already comes back in — a bare URL
594
+ # string, or a Hash carrying one under `url`/`continue_url` — before
595
+ # falling back to the bridge's own location.
596
+ def url_from_handoff(result)
597
+ from_result = case result
598
+ when String then result if result.start_with?("http")
599
+ when Hash then result["url"] || result["continue_url"]
600
+ end
601
+ from_result || (@webmcp_bridge.location if @webmcp_bridge.respond_to?(:location))
602
+ end
603
+
604
+ # docs/plans/webmcp-universal-outbound.md Phase 3. Three gates, in
605
+ # order, any of which means nothing gets typed:
606
+ # 1. WebmcpAutofillMode off (the default) — no attempt at all, same
607
+ # shape the report had before Phase 3 existed (no `autofill:` key).
608
+ # 2. Nothing to fill — no PORTAGE_SHIP_*/PORTAGE_SHIP_EMAIL
609
+ # configured.
610
+ # 3. The shopper doesn't approve the exact fields/values in
611
+ # #webmcp_autofill_confirm's prompt (decision 2) — including
612
+ # "can't even ask", under --json or with no TTY.
613
+ # Past those, Portage::Ucp::WebMcp::Autofill (portage-ucp-webmcp) does
614
+ # the actual work against the checkout page the bridge's browser has
615
+ # already navigated to; its own gates (headless?, a CAPTCHA/challenge
616
+ # on the page) apply independently of these three.
617
+ #
618
+ # @return [Hash, nil] nil when gate 1 or 2 stopped this before ever
619
+ # reaching the shopper — the report gets no `autofill:` key at all,
620
+ # same as before Phase 3. Otherwise a Hash with a stable `outcome:`
621
+ # (`autofill_declined`, `autofill_needs_headed_browser`,
622
+ # `autofill_blocked`, `autofill_unsupported`, `autofill_filled`) plus
623
+ # whatever `filled:`/`unmatched:`/`rate:` Autofill reported.
624
+ def attempt_webmcp_autofill(preset)
625
+ return nil unless webmcp_autofill_approved?
626
+
627
+ fields = Portage::Cli::WebmcpAutofillFields.build
628
+ return nil if fields.empty?
629
+ return { outcome: "autofill_declined" } unless webmcp_autofill_confirm.call(fields)
630
+
631
+ result = webmcp_autofill.call(bridge: @webmcp_bridge, fields: fields, selectors: preset.checkout_selectors)
632
+ { outcome: "autofill_#{result.outcome}", filled: result.filled, unmatched: result.unmatched,
633
+ rate: result.rate }
634
+ end
635
+
636
+ def webmcp_autofill_approved?
637
+ Portage::Cli::WebmcpAutofillMode.approved?(override: @autofill)
638
+ end
639
+
640
+ def webmcp_autofill_confirm
641
+ @webmcp_autofill_confirm ||= Portage::Cli::WebmcpAutofillConfirm.new(interactive: !@json && $stdin.tty?)
642
+ end
643
+
644
+ # Injectable so a spec can stand in for portage-ucp-webmcp's real
645
+ # Autofill without a real browser (same reasoning as
646
+ # webmcp_mappings/webmcp_mapping_confirm above) — nil by default
647
+ # rather than a constructor kwarg since it's only ever reached once
648
+ # `webmcp_bridge:` and autofill are both already in play, deep enough
649
+ # into #call that a kwarg here would rarely be worth threading through
650
+ # every other spec's Buy.new.
651
+ def webmcp_autofill
652
+ @webmcp_autofill ||= Portage::Ucp::WebMcp::Autofill
653
+ end
654
+
261
655
  def webmcp_checkout_mode
262
656
  Portage::Cli::WebmcpCheckoutMode.resolve
263
657
  end
@@ -513,11 +907,26 @@ module Portage
513
907
  # reached here, `buy <url> --max-price` checked out whatever the store
514
908
  # ranked first (confirmed live 2026-09-24: a $679.95 board on
515
909
  # burton.com under --max-price 600).
910
+ # A --product-id match also checks each product's own variants, not
911
+ # just its top-level id: OfferSources::ShopifyCatalog hands Find an
912
+ # offer whose product_id is the merchant's own variant gid (the
913
+ # catalog's own product id is a global one the merchant doesn't
914
+ # recognise — see that class), so the product carrying it is found by
915
+ # its variant, not its id.
516
916
  def select_product(products)
517
917
  products = products.select { |product| within_max_price?(product) }
518
918
  return products.find { |product| available?(product) } || products.first unless @product_id
519
919
 
520
- products.find { |product| product_id_of(product) == @product_id }
920
+ products.find { |product| product_id_of(product) == @product_id || variant_matching(product, @product_id) }
921
+ end
922
+
923
+ # nil `id` matches nothing — a product's variants are never searched
924
+ # for a nil id, the same way #select_product's own `unless @product_id`
925
+ # branch never gets here without one.
926
+ def variant_matching(product, id)
927
+ return nil unless id
928
+
929
+ Array(product["variants"]).find { |variant| variant["id"] == id }
521
930
  end
522
931
 
523
932
  # Priced the way #reconcile_checkout expects the store to charge: the
@@ -561,12 +970,17 @@ module Portage
561
970
  # first variant, for the same reason #select_product skips sold-out
562
971
  # products (a live Brooklinen sheet set lists its sold-out size first).
563
972
  def line_item_id_of(product)
973
+ matched = variant_matching(product, @product_id)
974
+ return matched["id"] if matched
975
+
564
976
  variants = Array(product["variants"])
565
977
  (variants.find { |variant| variant_available?(variant) } || variants.first)&.dig("id") ||
566
978
  product_id_of(product)
567
979
  end
568
980
 
569
981
  def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
982
+ return quote_changed_report(source, products, checkout, warnings) if quote_exceeded?(checkout)
983
+
570
984
  escalation = decide_escalation(checkout, warnings)
571
985
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
572
986
  return dry_run_report(source, products, checkout, warnings) if @dry_run
@@ -576,6 +990,33 @@ module Portage
576
990
  complete(session, source, products, checkout, warnings)
577
991
  end
578
992
 
993
+ # First in #finish_checkout, ahead of the escalation gates: a
994
+ # `quote_changed` refusal never hands off, since that would spend the
995
+ # quote and open a checkout the person never approved. #complete is
996
+ # the only place this file charges, and it is reached only through
997
+ # #finish_checkout.
998
+ def quote_exceeded?(checkout)
999
+ return false unless @quote_total
1000
+
1001
+ total = checkout_total(checkout)
1002
+ total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
1003
+ end
1004
+
1005
+ def quote_changed_report(source, products, checkout, warnings)
1006
+ total = checkout_total(checkout)
1007
+ checkout_report(source, products, checkout, outcome: "quote_changed", warnings: warnings,
1008
+ quoted_total: @quote_total, quoted_currency: @quote_currency,
1009
+ current_total: total, current_currency: checkout["currency"],
1010
+ message: quote_changed_message(total, checkout["currency"]))
1011
+ end
1012
+
1013
+ def quote_changed_message(total, currency)
1014
+ "The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
1015
+ "now #{quoted_amount(total, currency)}) — nothing was bought."
1016
+ end
1017
+
1018
+ def quoted_amount(amount, currency) = amount ? Money.format_amount(amount, currency) : "unknown"
1019
+
579
1020
  # Hand off vs. keep going is Decisions.escalation's call
580
1021
  # (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
581
1022
  # literal `requires_escalation` status always escalates. A mismatch
@@ -773,11 +1214,14 @@ module Portage
773
1214
  # (and fires the auto-open/webhook side effects) rather than leaving
774
1215
  # them at a dead end. `outcome` doubles as the webhook's `reason`, so a
775
1216
  # relay and an agent loop branch on the same value.
776
- def handoff_report(source, products, checkout, warnings, outcome:, message:)
1217
+ # @param extra [Hash] merged straight onto the report — Phase 3's
1218
+ # `autofill:` (see #webmcp_handoff_report) is the only caller today.
1219
+ def handoff_report(source, products, checkout, warnings, outcome:, message:, extra: {})
777
1220
  handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
778
1221
  checkout_report(source, products, checkout, outcome: outcome, message: message,
779
1222
  warnings: warnings + Array(@pending_handoff_warning),
780
- checkout_url: checkout_url_of(checkout), handoff: handoff)
1223
+ checkout_url: checkout_url_of(checkout), handoff: handoff,
1224
+ **extra)
781
1225
  end
782
1226
 
783
1227
  # docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
@@ -786,10 +1230,18 @@ module Portage
786
1230
  # auto-open/notify exactly like any other hand-off (Phases 1-3 apply
787
1231
  # unchanged), even though it got here because a mode setting chose to
788
1232
  # stop, not because anything was denied or escalated.
789
- def webmcp_handoff_report(source, products, checkout, warnings)
1233
+ #
1234
+ # @param autofill [Hash, nil] docs/plans/webmcp-universal-outbound.md
1235
+ # Phase 3 — #webmcp_handoff_checkout_flow's own autofill attempt
1236
+ # (#attempt_webmcp_autofill), attached as the report's `autofill:`
1237
+ # key when there was one to attach. nil (the default, and always for
1238
+ # #finish_checkout's own force_handoff call) adds no key at all —
1239
+ # the report is byte-for-byte what it was before Phase 3 existed.
1240
+ def webmcp_handoff_report(source, products, checkout, warnings, autofill: nil)
790
1241
  message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
791
1242
  "on the page."
792
- handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
1243
+ extra = autofill ? { autofill: autofill } : {}
1244
+ handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message, extra: extra)
793
1245
  end
794
1246
 
795
1247
  # Never fires on --dry-run (a dry run creates a real checkout but never
@@ -816,20 +1268,98 @@ module Portage
816
1268
  over_cap = precheck_mode? && over_spend_cap?(checkout)
817
1269
  record_pending_handoff(checkout, reason: reason)
818
1270
 
819
- opened = over_cap ? false : CheckoutHandoff.new(auto_open: @auto_open).call(url)
820
- error = notifier.call(handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
821
- warnings: warnings, over_cap: over_cap))
822
- result = { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
823
- over_cap ? result.merge(over_cap: true) : result
824
- end
825
-
1271
+ payload = handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
1272
+ warnings: warnings, over_cap: over_cap)
1273
+ dispatch = over_cap ? { target: handoff_target.label, opened: false } : dispatch_to_target(url, payload)
1274
+ error = notifier.call(payload)
1275
+ finalize_handoff(url, dispatch, notified: notifier.enabled? && error.nil?, notify_error: error,
1276
+ over_cap: over_cap)
1277
+ end
1278
+
1279
+ # Same payload for both `--notify-webhook` and `agent:<name>`
1280
+ # (docs/plans/buy-skill-and-local-browser.md Phase 5, decision 3): the
1281
+ # checkout URL plus the approved cart summary — items, qty (each
1282
+ # item's own `quantity`), total (`totals`) and store. Reused as-is
1283
+ # for #handoff_only_report with `checkout: {}` — no cart was ever
1284
+ # built there, so `checkout_id`/`totals`/`items` come back nil/empty,
1285
+ # never fabricated.
826
1286
  def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
827
1287
  payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
828
1288
  query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
829
- totals: checkout["totals"], warnings: warnings }
1289
+ totals: checkout["totals"], items: checkout_items(checkout), warnings: warnings }
830
1290
  over_cap ? payload.merge(over_cap: true) : payload
831
1291
  end
832
1292
 
1293
+ # docs/plans/buy-skill-and-local-browser.md Phase 5 — routes a
1294
+ # hand-off to whichever target `--handoff-target`/PORTAGE_HANDOFF_TARGET/
1295
+ # config.json resolved to. `payload` is only used by the `agent`
1296
+ # branch; building it unconditionally is cheap and keeps this method
1297
+ # a plain dispatch with no branching on whether it's needed.
1298
+ def dispatch_to_target(url, payload)
1299
+ case handoff_target.kind
1300
+ when "print" then { target: "print", opened: false }
1301
+ when "profile" then dispatch_to_profile(url)
1302
+ when "agent" then dispatch_to_agent(payload)
1303
+ else { target: "default", opened: CheckoutHandoff.new(auto_open: @auto_open).call(url) }
1304
+ end
1305
+ end
1306
+
1307
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: when this run
1308
+ # already has a Portage browser profile bridge attached (`Cli.
1309
+ # profile_webmcp_bridge`, only when `--handoff-target profile`
1310
+ # resolved and the profile was running), navigate that same browser
1311
+ # to the checkout URL — the cart was built there too, when the
1312
+ # WebMCP flow ran, or is about to be paid there for the first time,
1313
+ # for a native-UCP store. `#navigate` permits the URL's own host
1314
+ # first (the "plus its checkout host" half of the allowlist), so
1315
+ # this is never blocked by the allowlist itself; it's the one
1316
+ # deliberate Portage-initiated navigation the allowlist always lets
1317
+ # through. Any other bridge failure (the profile process died mid
1318
+ # run, a stale WebSocket) is reported rather than raised, same
1319
+ # posture as every other hand-off dispatch.
1320
+ def dispatch_to_profile(url)
1321
+ unless @webmcp_bridge.respond_to?(:navigate)
1322
+ return { target: "profile", opened: false,
1323
+ message: "No Portage browser profile is attached — run `portage browser profile open` " \
1324
+ "first, then retry with --handoff-target profile." }
1325
+ end
1326
+
1327
+ @webmcp_bridge.navigate(url)
1328
+ { target: "profile", opened: true }
1329
+ rescue StandardError => e
1330
+ { target: "profile", opened: false, message: "Couldn't open the checkout in the Portage browser " \
1331
+ "profile (#{e.message}) — showing the link instead." }
1332
+ end
1333
+
1334
+ def dispatch_to_agent(payload)
1335
+ agent = handoff_agents.lookup(handoff_target.agent_name)
1336
+ unless agent
1337
+ return { target: handoff_target.label, opened: false, agent_delivered: false,
1338
+ agent_error: "#{handoff_target.agent_name.inspect} isn't approved in " \
1339
+ "~/.portage/config.json's handoff_agents — showing the link instead." }
1340
+ end
1341
+
1342
+ error = agent.call(payload)
1343
+ { target: handoff_target.label, opened: false, agent_delivered: error.nil?, agent_error: error }
1344
+ end
1345
+
1346
+ def finalize_handoff(url, dispatch, notified:, notify_error:, over_cap: false)
1347
+ result = { url: url, opened: dispatch[:opened], notified: notified, notify_error: notify_error,
1348
+ handoff_target: dispatch[:target] }
1349
+ result[:agent_delivered] = dispatch[:agent_delivered] if dispatch.key?(:agent_delivered)
1350
+ result[:agent_error] = dispatch[:agent_error] if dispatch.key?(:agent_error)
1351
+ result[:target_message] = dispatch[:message] if dispatch[:message]
1352
+ over_cap ? result.merge(over_cap: true) : result
1353
+ end
1354
+
1355
+ def handoff_target
1356
+ @handoff_target ||= HandoffTarget.new
1357
+ end
1358
+
1359
+ def handoff_agents
1360
+ @handoff_agents ||= HandoffAgents.new
1361
+ end
1362
+
833
1363
  # Never raises: a record that can't be written is a warning on the
834
1364
  # report (surfaced via @pending_handoff_warning, see #handoff_report),
835
1365
  # not a reason to fail the hand-off itself — same posture as
@@ -918,12 +1448,12 @@ module Portage
918
1448
  # is what the checkout actually holds; `products:` is only what the
919
1449
  # search returned, most of which was never bought.
920
1450
  def checkout_report(source, products, checkout, outcome:, message:, checkout_url: nil, handoff: nil,
921
- warnings: [])
1451
+ warnings: [], **extra)
922
1452
  build_report(source: source, outcome: outcome, browse: true, checkout: true, products: products,
923
1453
  message: message, checkout_url: checkout_url, checkout_id: checkout["id"],
924
1454
  checkout_status: checkout["status"], currency: checkout["currency"],
925
1455
  totals: checkout["totals"], items: checkout_items(checkout), handoff: handoff,
926
- warnings: warnings, decisions: @decisions.dup)
1456
+ warnings: warnings, decisions: @decisions.dup, **extra)
927
1457
  end
928
1458
 
929
1459
  def checkout_items(checkout)
@@ -959,7 +1489,7 @@ module Portage
959
1489
  # loopback path ignores it harmlessly, so it's cheapest to always pass
960
1490
  # it rather than branch on which transport `session` happens to be.
961
1491
  def agent_meta
962
- { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
1492
+ { agent_profile: AgentProfileUrl.resolve }
963
1493
  end
964
1494
 
965
1495
  # --- Homepage fetch (used by both the manifest-not-found path and the