portage-cli 0.7.4 → 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +580 -0
  3. data/README.md +322 -19
  4. data/exe/portage +5 -0
  5. data/exe/portage-console +3 -0
  6. data/known-stores/categories.yml +1263 -0
  7. data/lib/portage/cli/agent_profile_url.rb +30 -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_profile/allowlist.rb +40 -0
  21. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  22. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  23. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  24. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  25. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  26. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  27. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  28. data/lib/portage/cli/browser_profile.rb +25 -0
  29. data/lib/portage/cli/buy.rb +521 -29
  30. data/lib/portage/cli/classifier.rb +158 -0
  31. data/lib/portage/cli/compare.rb +3 -0
  32. data/lib/portage/cli/doctor.rb +195 -7
  33. data/lib/portage/cli/dot_env.rb +117 -0
  34. data/lib/portage/cli/find.rb +96 -12
  35. data/lib/portage/cli/handoff_agents.rb +186 -0
  36. data/lib/portage/cli/handoff_only.rb +94 -0
  37. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  38. data/lib/portage/cli/handoff_target.rb +61 -0
  39. data/lib/portage/cli/index/builder.rb +335 -0
  40. data/lib/portage/cli/index/exporter.rb +91 -0
  41. data/lib/portage/cli/index/known_cache.rb +155 -0
  42. data/lib/portage/cli/index/product_store.rb +101 -0
  43. data/lib/portage/cli/index/sources/browser.rb +31 -0
  44. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  45. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  46. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  47. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  48. data/lib/portage/cli/index/sources.rb +44 -0
  49. data/lib/portage/cli/index/store.rb +109 -0
  50. data/lib/portage/cli/index.rb +20 -0
  51. data/lib/portage/cli/known_stores_url.rb +15 -0
  52. data/lib/portage/cli/offer_sources.rb +460 -0
  53. data/lib/portage/cli/payment_methods.rb +24 -3
  54. data/lib/portage/cli/search_backends.rb +337 -12
  55. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  56. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  57. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  58. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  59. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  60. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  61. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  62. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  63. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  64. data/lib/portage/cli/setup_wizard.rb +74 -0
  65. data/lib/portage/cli/version.rb +1 -1
  66. data/lib/portage/cli/webmcp.rb +10 -3
  67. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  68. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  69. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  70. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  71. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  72. data/lib/portage/cli.rb +528 -6
  73. metadata +59 -2
@@ -4,12 +4,17 @@ 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"
12
13
  require_relative "notifier"
14
+ require_relative "handoff_only"
15
+ require_relative "offer_sources"
16
+ require_relative "handoff_target"
17
+ require_relative "handoff_agents"
13
18
  require_relative "user_agent"
14
19
  require_relative "homepage_fetch"
15
20
  require_relative "permissive_authenticator"
@@ -17,6 +22,11 @@ require_relative "handoff_reconciler"
17
22
  require_relative "handoff_spend_mode"
18
23
  require_relative "webmcp"
19
24
  require_relative "webmcp_checkout_mode"
25
+ require_relative "webmcp_mappings"
26
+ require_relative "webmcp_mapping_confirm"
27
+ require_relative "webmcp_autofill_mode"
28
+ require_relative "webmcp_autofill_fields"
29
+ require_relative "webmcp_autofill_confirm"
20
30
 
21
31
  module Portage
22
32
  module Cli
@@ -47,6 +57,13 @@ module Portage
47
57
  # webhook URL a dead-end checkout_url is POSTed to — nil (the
48
58
  # default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
49
59
  # Notifier).
60
+ # @param handoff_target [HandoffTarget, nil] docs/plans/
61
+ # buy-skill-and-local-browser.md Phase 5 — which of default/print/
62
+ # profile/agent:<name> a hand-off dispatches to. nil (the default)
63
+ # builds one from PORTAGE_HANDOFF_TARGET/config.json with no
64
+ # per-invocation override — `Cli.run_buy` builds and validates one
65
+ # from `--handoff-target` up front instead, the same posture as
66
+ # `confidence_check:`.
50
67
  # @param confidence_check [ConfidenceCheck, nil] the opt-in confidence
51
68
  # gate in front of an unattended completion. nil (the default) builds
52
69
  # one from PORTAGE_DECISION_BACKEND / PORTAGE_MIN_CONFIDENCE, which is
@@ -63,17 +80,48 @@ module Portage
63
80
  # @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
64
81
  # handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
65
82
  # 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
83
+ # already holds. nil (the default) skips WebMCP entirely — zero
84
+ # behavior change from before this parameter existed. Given one,
85
+ # attempted after native-UCP discovery finds nothing at this URL
86
+ # and before a platform-adapter fallback (see #webmcp_flow).
87
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: `portage buy`
88
+ # from the shell now builds one itself — a
89
+ # Portage::Cli::BrowserProfile::Bridge onto the Portage browser
90
+ # profile — whenever `--handoff-target profile` resolves and that
91
+ # profile is running (`Cli.profile_webmcp_bridge`); any other
92
+ # target still leaves this nil, the CLI's original "no browser of
93
+ # its own" posture.
94
+ # @param webmcp_mappings [Portage::Cli::WebmcpMappings, nil] Phase 2's
95
+ # confirmed-mapping store (docs/plans/webmcp-universal-outbound.md).
96
+ # nil (the default) builds the real `~/.portage/webmcp_mappings.json`
97
+ # -backed one lazily, only if #webmcp_matched_session ever runs —
98
+ # injectable so specs don't touch it.
99
+ # @param webmcp_mapping_confirm [Portage::Cli::WebmcpMappingConfirm, nil]
100
+ # Phase 2's confirm-before-mutate gate. nil (the default) builds one
101
+ # from whether stdin is a real TTY and `json:` is off — injectable so
102
+ # a spec can simulate an interactive "y" without a real terminal.
103
+ # @param json [Boolean] whether this run is `--json` — Phase 2's
104
+ # mapping confirmation prompt never fires under it (same posture as
105
+ # "no TTY"), since a caller reading structured stdout has nowhere to
106
+ # put an interactive prompt.
107
+ # @param autofill [Boolean, nil] docs/plans/webmcp-universal-outbound.md
108
+ # Phase 3 — `--autofill`'s value: true when the flag was passed, nil
109
+ # (the default) otherwise. Only ever turns WebmcpAutofillMode on;
110
+ # `PORTAGE_WEBMCP_AUTOFILL=approve`/config.json still work with this
111
+ # left nil. Approving the mode is still only half of Phase 3's
112
+ # opt-in — see #webmcp_autofill_confirm.
113
+ # @param webmcp_autofill_confirm [Portage::Cli::WebmcpAutofillConfirm, nil]
114
+ # Phase 3's per-run "fill exactly these fields?" prompt. nil (the
115
+ # default) builds one from the same interactive? posture as Phase
116
+ # 2's webmcp_mapping_confirm; injectable so a spec can simulate an
117
+ # interactive "y" without a real terminal.
118
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
119
+ # injectable collaborators, each assigned to its own ivar
73
120
  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
121
+ auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
122
+ transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
123
+ webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false)
124
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
77
125
  raw = url.to_s.strip
78
126
  @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
79
127
  @query = query
@@ -84,9 +132,15 @@ module Portage
84
132
  @product_id = product_id
85
133
  @auto_open = auto_open
86
134
  @notify_webhook = notify_webhook
135
+ @handoff_target = handoff_target
87
136
  @confidence_check = confidence_check
88
137
  @transaction_log = transaction_log
89
138
  @max_price = max_price
139
+ @webmcp_mappings = webmcp_mappings
140
+ @webmcp_mapping_confirm = webmcp_mapping_confirm
141
+ @autofill = autofill
142
+ @webmcp_autofill_confirm = webmcp_autofill_confirm
143
+ @json = json
90
144
  @webmcp_bridge = webmcp_bridge
91
145
  @decisions = {}
92
146
  end
@@ -95,6 +149,8 @@ module Portage
95
149
  POLICY_LINK_TYPES = /policy|policies|terms|contact|privacy|legal|imprint/i
96
150
 
97
151
  def call
152
+ return handoff_only_report if handoff_only?
153
+
98
154
  session = discover(@uri)
99
155
  return native_flow(session) if session
100
156
 
@@ -115,6 +171,90 @@ module Portage
115
171
 
116
172
  private
117
173
 
174
+ # --- Step 0: hand-off-only hosts (Tier C) ---
175
+
176
+ # Checked before anything else in #call so a hand-off-only host never
177
+ # sees a single request from this process — no UCP probe, no
178
+ # homepage fetch, no cart (docs/plans/buy-skill-and-local-browser.md
179
+ # Phase 5, decision 5). Amazon by default; ~/.portage/config.json's
180
+ # "handoff_only_hosts" is the user's own list (see HandoffOnly).
181
+ def handoff_only?
182
+ @handoff_only ||= HandoffOnly.new
183
+ return true if @handoff_only.host?(@uri.host)
184
+ return true if OfferSources.retail_handoff_host?(@uri.host)
185
+
186
+ etsy_buyer_host?
187
+ end
188
+
189
+ # Phase 7 (docs/plans/buy-skill-and-local-browser.md):
190
+ # portage-ucp-etsy is a *seller*-side adapter — a shop owner with
191
+ # their own ETSY_* credentials set still reaches #adapter_flow
192
+ # unchanged below, the same "only your own store" rule every other
193
+ # adapter already gets (see the class comment at the top of this
194
+ # file). An ordinary buyer with no Etsy credentials of their own
195
+ # gets routed to hand-off here instead of a homepage fetch +
196
+ # platform detection that would just be scraping a stranger's
197
+ # listing — the buyer-side offer OfferSources::EtsyListings hands
198
+ # `find` has nothing this process can check out anyway.
199
+ def etsy_buyer_host?
200
+ HandoffOnly.matches_any?(@uri.host, %w[etsy.com]) && !etsy_adapter_configured?
201
+ end
202
+
203
+ def etsy_adapter_configured?
204
+ platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
205
+ return false unless platform
206
+
207
+ Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
208
+ end
209
+
210
+ # No checkout was ever built — there's nothing to browse or complete,
211
+ # just a link and why. `checkout_url` is built, never fetched: the
212
+ # product page/cart-add URL when a product id is known (Amazon's own
213
+ # URL shape only — that's the only pattern this phase knows), or the
214
+ # retailer's own search URL for the query, or (a user-added host with
215
+ # no known pattern) the origin's homepage.
216
+ def handoff_only_report
217
+ url = handoff_only_checkout_url
218
+ handoff = @dry_run ? nil : dispatch_handoff_only(url)
219
+ build_report(source: "handoff_only", outcome: "handoff_only", browse: false, checkout: false,
220
+ checkout_url: url, legal_notice: HandoffOnly::LEGAL_NOTICE, handoff: handoff,
221
+ message: "#{@uri.host} restricts automated purchasing agents — Portage opens the page " \
222
+ "and you buy.")
223
+ end
224
+
225
+ def dispatch_handoff_only(url)
226
+ payload = handoff_notify_payload({}, url, reason: "handoff_only", source: "handoff_only",
227
+ message: "Hand-off-only retailer — no automated checkout " \
228
+ "was attempted.", warnings: [], over_cap: false)
229
+ finalize_handoff(url, dispatch_to_target(url, payload), notified: false, notify_error: nil)
230
+ end
231
+
232
+ def handoff_only_checkout_url
233
+ return amazon_checkout_url if HandoffOnly.amazon?(@uri.host)
234
+ return @uri.to_s if OfferSources.retail_handoff_host?(@uri.host) || etsy_buyer_host?
235
+
236
+ origin_homepage
237
+ end
238
+
239
+ def amazon_checkout_url
240
+ return amazon_cart_add_url if @product_id
241
+ return amazon_search_url unless @query.to_s.strip.empty?
242
+
243
+ origin_homepage
244
+ end
245
+
246
+ def amazon_search_url
247
+ "#{origin}/s?k=#{URI.encode_www_form_component(@query.to_s)}"
248
+ end
249
+
250
+ def amazon_cart_add_url
251
+ "#{origin}/gp/aws/cart/add.html?ASIN.1=#{URI.encode_www_form_component(@product_id.to_s)}&Quantity.1=#{@qty}"
252
+ end
253
+
254
+ def origin_homepage = "#{origin}/"
255
+
256
+ def origin = "#{@uri.scheme}://#{@uri.host}"
257
+
118
258
  # --- Step 1: native UCP manifest ---
119
259
 
120
260
  def discover(url)
@@ -242,22 +382,267 @@ module Portage
242
382
  # That hand-off feeds Phases 1-3 completely unchanged: reason
243
383
  # `express_stop` reserves a pending record, notifies, and later
244
384
  # reconciles exactly like any other hand-off.
385
+ #
386
+ # `preset_name` is resolved once here (docs/plans/
387
+ # webmcp-universal-outbound.md Phase 1, decision 1) and handed to
388
+ # `connect` explicitly (a nil/Symbol either way skips its own
389
+ # detection) so the same answer also tells this method whether the
390
+ # page's checkout capability came from a real `create_checkout` tool
391
+ # or only from a preset's hand-off-only one — see
392
+ # #webmcp_handoff_checkout_flow.
393
+ #
394
+ # When a plain connect (no preset, no tool_names:) doesn't advertise
395
+ # cart+checkout, and no preset matched, #webmcp_matched_session (Phase
396
+ # 2) is the fallback: a confirmed mapping from a prior run, or a fresh
397
+ # one proposed by `Matcher` and confirmed here. A page that already
398
+ # speaks either a preset's names or the plain UCP action names never
399
+ # reaches it at all — Transport's own tool_names:/bare-action
400
+ # resolution already covers both, with nothing to confirm.
245
401
  def webmcp_flow
246
402
  return nil unless @webmcp_bridge
247
403
  return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
248
404
 
249
- session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
250
- return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
405
+ preset_name = webmcp_preset_name
406
+ session, unresolved = webmcp_session(preset_name)
407
+ return unresolved if unresolved
408
+ return nil unless session
251
409
 
252
410
  return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
253
411
 
254
- full_buy(session, source: "webmcp", force_handoff: true)
412
+ run_webmcp_checkout(session, preset_name)
255
413
  rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
256
414
  Portage::Ucp::Client::ServerError => e
415
+ # A Portage::Cli::BrowserProfile::DomainNotAllowedError (Phase 6's
416
+ # domain allowlist, tripped inside Bridge#evaluate) always surfaces
417
+ # here as a BridgeError — Bridges::ScriptEvaluator#evaluate wraps
418
+ # whatever its injected evaluate: callable raises, StandardError
419
+ # included, so there's no separate rescue clause for it.
257
420
  build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
258
421
  message: "WebMCP checkout failed: #{e.message}")
259
422
  end
260
423
 
424
+ # Split out of #webmcp_flow just to keep its own branching (bridge
425
+ # given?, gem installed?, cart/checkout capable?, token mode?) from
426
+ # also carrying the Phase 2 fallback fork (Metrics/CyclomaticComplexity)
427
+ # — same reasoning as #run_webmcp_checkout's own split.
428
+ #
429
+ # @return [Array(Session, nil)] a cart/checkout-capable session.
430
+ # @return [Array(nil, nil)] no cart/checkout capability anywhere —
431
+ # #webmcp_flow falls through to adapter detection, same as before
432
+ # Phase 2 existed.
433
+ # @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report.
434
+ def webmcp_session(preset_name)
435
+ session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: preset_name)
436
+ return [session, nil] if webmcp_cart_and_checkout?(session)
437
+ return [nil, nil] if preset_name
438
+
439
+ matched, unresolved = webmcp_matched_session
440
+ return [nil, unresolved] if unresolved
441
+ return [nil, nil] unless webmcp_cart_and_checkout?(matched)
442
+
443
+ [matched, nil]
444
+ end
445
+
446
+ def webmcp_cart_and_checkout?(session)
447
+ session&.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
448
+ end
449
+
450
+ # Phase 2 fallback (docs/plans/webmcp-universal-outbound.md): a
451
+ # previously confirmed mapping for this exact tool fingerprint is
452
+ # reused with no prompt (decision 3); otherwise `Matcher.propose`
453
+ # builds one and #webmcp_mapping_confirm decides whether it can be
454
+ # used — reads unconditionally, mutating actions only once approved.
455
+ #
456
+ # @return [Array(Session, nil)] a session built with the resolved
457
+ # tool_names:, when there was anything to resolve.
458
+ # @return [Array(nil, nil)] nothing usable — the page has no tools a
459
+ # candidate scored high enough against; #webmcp_flow's own
460
+ # post-connect capability check reports it same as any other
461
+ # cart/checkout-incapable page.
462
+ # @return [Array(nil, Hash)] a webmcp_mapping_unconfirmed report, when
463
+ # a mutating action was proposed but couldn't be confirmed.
464
+ def webmcp_matched_session
465
+ tools = @webmcp_bridge.list_tools
466
+ tool_names = webmcp_mappings.lookup(tools)
467
+
468
+ if tool_names.nil?
469
+ proposal = Portage::Ucp::WebMcp::Matcher.propose(tools)
470
+ return [nil, nil] if proposal.empty?
471
+
472
+ tool_names = webmcp_mapping_confirm.call(proposal, tools)
473
+ return [nil, webmcp_mapping_unconfirmed_report(proposal)] if tool_names.nil?
474
+
475
+ webmcp_mappings.confirm!(tools, tool_names: tool_names, origin: @uri.host)
476
+ end
477
+
478
+ [Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge, preset: nil, tool_names: tool_names), nil]
479
+ end
480
+
481
+ def webmcp_mappings
482
+ @webmcp_mappings ||= Portage::Cli::WebmcpMappings.load
483
+ end
484
+
485
+ def webmcp_mapping_confirm
486
+ @webmcp_mapping_confirm ||= Portage::Cli::WebmcpMappingConfirm.new(interactive: !@json && $stdin.tty?)
487
+ end
488
+
489
+ def webmcp_mapping_unconfirmed_report(proposal)
490
+ build_report(
491
+ source: "webmcp", outcome: "webmcp_mapping_unconfirmed", browse: false, checkout: false,
492
+ message: "This page's WebMCP tools don't match a known preset. Here's the proposed mapping — " \
493
+ "confirm it and retry, passing it back as tool_names:, or run this interactively (a real " \
494
+ "TTY, no --json) to confirm it now.",
495
+ tool_names_proposal: webmcp_serialize_proposal(proposal)
496
+ )
497
+ end
498
+
499
+ def webmcp_serialize_proposal(proposal)
500
+ proposal.transform_values do |match|
501
+ { tool_name: match.tool, confidence: match.confidence, reason: match.reason }
502
+ end
503
+ end
504
+
505
+ def webmcp_preset_name
506
+ Portage::Ucp::WebMcp::Presets.detect(@webmcp_bridge.list_tools)
507
+ end
508
+
509
+ # Split out of #webmcp_flow just to keep that method's own branching
510
+ # (bridge given?, gem installed?, cart/checkout capable?, token mode?)
511
+ # from also carrying this preset-shaped fork (Metrics/CyclomaticComplexity).
512
+ def run_webmcp_checkout(session, preset_name)
513
+ preset = preset_name && Portage::Ucp::WebMcp::Presets.fetch(preset_name)
514
+ return webmcp_handoff_checkout_flow(session, preset) if preset&.handoff_checkout
515
+
516
+ full_buy(session, source: "webmcp", force_handoff: true)
517
+ end
518
+
519
+ # A preset whose fingerprint has no `create_checkout` tool at all —
520
+ # Shopify's is the only one so far (see Presets::SHOPIFY) — can't run
521
+ # #full_buy's `session.create_checkout`; there's nothing on the page to
522
+ # call it against. Builds a cart, reads it back through the (already
523
+ # cart/checkout-capable, per the gate in #webmcp_flow) session so
524
+ # #reconcile_checkout has a `get_cart`-shaped document to check against
525
+ # (there's no checkout document either), then calls the preset's
526
+ # `handoff_checkout` tool directly on the bridge — after the cart
527
+ # read-back, not before, so any post-mutation "page not ready" gap
528
+ # (see Transport's own retry) has already been waited out by then.
529
+ #
530
+ # The checkout URL comes from whatever the hand-off tool itself
531
+ # returns, or — Shopify's `proceed_to_checkout` is a navigation, not
532
+ # data, and returns nothing url-shaped (README "Shopify storefronts")
533
+ # — from the bridge's own `#location` once the tab has navigated
534
+ # there, when the bridge offers one (Bridges::ScriptEvaluator does).
535
+ #
536
+ # Once the tab has navigated to that checkout page, this is also
537
+ # where Phase 3's autofill runs (#attempt_webmcp_autofill) — the bridge's
538
+ # browser is sitting on the store's own checkout, cart already built,
539
+ # which is exactly the moment (and the only flow) autofill needs: a
540
+ # real checkout DOM, not a UCP checkout wire object #full_buy never
541
+ # navigates a browser to at all.
542
+ def webmcp_handoff_checkout_flow(session, preset)
543
+ products = safe_search(session)
544
+ product = select_product(products)
545
+ unless product
546
+ return build_report(source: "webmcp", outcome: "no_match", browse: true, checkout: true,
547
+ products: products, message: no_match_message)
548
+ end
549
+ return webmcp_handoff_dry_run_report(products, product, preset) if @dry_run
550
+
551
+ created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
552
+ context: buyer_context, meta: agent_meta)
553
+ cart = session.get_cart(cart_id: created["id"], meta: agent_meta)
554
+ warnings = reconcile_checkout(product, cart)
555
+
556
+ result = @webmcp_bridge.execute_tool(preset.handoff_checkout, {})
557
+ autofill = attempt_webmcp_autofill(preset)
558
+ webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), warnings,
559
+ autofill: autofill)
560
+ end
561
+
562
+ # Unlike #full_buy's dry run (which still creates a UCP checkout, since
563
+ # that's a document the store drops on its own), a dry run here stops
564
+ # before `create_cart`: every step after it changes something outside
565
+ # this process — a real cart on the store, the bridge's own tab
566
+ # navigated to checkout, and (with autofill on) typing into that page.
567
+ # None of that is safe to do on a preview run, so this only reports
568
+ # what the run would have done. No checkout_id either, so History
569
+ # records it as a search, not a purchase.
570
+ def webmcp_handoff_dry_run_report(products, product, preset)
571
+ build_report(
572
+ source: "webmcp", outcome: "dry_run", browse: true, checkout: true, products: products, handoff: nil,
573
+ decisions: @decisions.dup,
574
+ would: { line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
575
+ handoff_checkout: preset.handoff_checkout, autofill: webmcp_autofill_approved? },
576
+ message: "Dry run — would add #{product['title'] || line_item_id_of(product)} to the store's cart and " \
577
+ "hand off through #{preset.handoff_checkout}. Nothing was added, and no checkout was opened."
578
+ )
579
+ end
580
+
581
+ # Best-effort: the hand-off tool's own result shape isn't documented
582
+ # anywhere in this repo (see Presets::SHOPIFY's comment on
583
+ # `update_cart_lines`/`proceed_to_checkout`), so this only recognizes
584
+ # the shapes any WebMCP tool result already comes back in — a bare URL
585
+ # string, or a Hash carrying one under `url`/`continue_url` — before
586
+ # falling back to the bridge's own location.
587
+ def url_from_handoff(result)
588
+ from_result = case result
589
+ when String then result if result.start_with?("http")
590
+ when Hash then result["url"] || result["continue_url"]
591
+ end
592
+ from_result || (@webmcp_bridge.location if @webmcp_bridge.respond_to?(:location))
593
+ end
594
+
595
+ # docs/plans/webmcp-universal-outbound.md Phase 3. Three gates, in
596
+ # order, any of which means nothing gets typed:
597
+ # 1. WebmcpAutofillMode off (the default) — no attempt at all, same
598
+ # shape the report had before Phase 3 existed (no `autofill:` key).
599
+ # 2. Nothing to fill — no PORTAGE_SHIP_*/PORTAGE_SHIP_EMAIL
600
+ # configured.
601
+ # 3. The shopper doesn't approve the exact fields/values in
602
+ # #webmcp_autofill_confirm's prompt (decision 2) — including
603
+ # "can't even ask", under --json or with no TTY.
604
+ # Past those, Portage::Ucp::WebMcp::Autofill (portage-ucp-webmcp) does
605
+ # the actual work against the checkout page the bridge's browser has
606
+ # already navigated to; its own gates (headless?, a CAPTCHA/challenge
607
+ # on the page) apply independently of these three.
608
+ #
609
+ # @return [Hash, nil] nil when gate 1 or 2 stopped this before ever
610
+ # reaching the shopper — the report gets no `autofill:` key at all,
611
+ # same as before Phase 3. Otherwise a Hash with a stable `outcome:`
612
+ # (`autofill_declined`, `autofill_needs_headed_browser`,
613
+ # `autofill_blocked`, `autofill_unsupported`, `autofill_filled`) plus
614
+ # whatever `filled:`/`unmatched:`/`rate:` Autofill reported.
615
+ def attempt_webmcp_autofill(preset)
616
+ return nil unless webmcp_autofill_approved?
617
+
618
+ fields = Portage::Cli::WebmcpAutofillFields.build
619
+ return nil if fields.empty?
620
+ return { outcome: "autofill_declined" } unless webmcp_autofill_confirm.call(fields)
621
+
622
+ result = webmcp_autofill.call(bridge: @webmcp_bridge, fields: fields, selectors: preset.checkout_selectors)
623
+ { outcome: "autofill_#{result.outcome}", filled: result.filled, unmatched: result.unmatched,
624
+ rate: result.rate }
625
+ end
626
+
627
+ def webmcp_autofill_approved?
628
+ Portage::Cli::WebmcpAutofillMode.approved?(override: @autofill)
629
+ end
630
+
631
+ def webmcp_autofill_confirm
632
+ @webmcp_autofill_confirm ||= Portage::Cli::WebmcpAutofillConfirm.new(interactive: !@json && $stdin.tty?)
633
+ end
634
+
635
+ # Injectable so a spec can stand in for portage-ucp-webmcp's real
636
+ # Autofill without a real browser (same reasoning as
637
+ # webmcp_mappings/webmcp_mapping_confirm above) — nil by default
638
+ # rather than a constructor kwarg since it's only ever reached once
639
+ # `webmcp_bridge:` and autofill are both already in play, deep enough
640
+ # into #call that a kwarg here would rarely be worth threading through
641
+ # every other spec's Buy.new.
642
+ def webmcp_autofill
643
+ @webmcp_autofill ||= Portage::Ucp::WebMcp::Autofill
644
+ end
645
+
261
646
  def webmcp_checkout_mode
262
647
  Portage::Cli::WebmcpCheckoutMode.resolve
263
648
  end
@@ -513,11 +898,26 @@ module Portage
513
898
  # reached here, `buy <url> --max-price` checked out whatever the store
514
899
  # ranked first (confirmed live 2026-09-24: a $679.95 board on
515
900
  # burton.com under --max-price 600).
901
+ # A --product-id match also checks each product's own variants, not
902
+ # just its top-level id: OfferSources::ShopifyCatalog hands Find an
903
+ # offer whose product_id is the merchant's own variant gid (the
904
+ # catalog's own product id is a global one the merchant doesn't
905
+ # recognise — see that class), so the product carrying it is found by
906
+ # its variant, not its id.
516
907
  def select_product(products)
517
908
  products = products.select { |product| within_max_price?(product) }
518
909
  return products.find { |product| available?(product) } || products.first unless @product_id
519
910
 
520
- products.find { |product| product_id_of(product) == @product_id }
911
+ products.find { |product| product_id_of(product) == @product_id || variant_matching(product, @product_id) }
912
+ end
913
+
914
+ # nil `id` matches nothing — a product's variants are never searched
915
+ # for a nil id, the same way #select_product's own `unless @product_id`
916
+ # branch never gets here without one.
917
+ def variant_matching(product, id)
918
+ return nil unless id
919
+
920
+ Array(product["variants"]).find { |variant| variant["id"] == id }
521
921
  end
522
922
 
523
923
  # Priced the way #reconcile_checkout expects the store to charge: the
@@ -561,6 +961,9 @@ module Portage
561
961
  # first variant, for the same reason #select_product skips sold-out
562
962
  # products (a live Brooklinen sheet set lists its sold-out size first).
563
963
  def line_item_id_of(product)
964
+ matched = variant_matching(product, @product_id)
965
+ return matched["id"] if matched
966
+
564
967
  variants = Array(product["variants"])
565
968
  (variants.find { |variant| variant_available?(variant) } || variants.first)&.dig("id") ||
566
969
  product_id_of(product)
@@ -773,11 +1176,14 @@ module Portage
773
1176
  # (and fires the auto-open/webhook side effects) rather than leaving
774
1177
  # them at a dead end. `outcome` doubles as the webhook's `reason`, so a
775
1178
  # relay and an agent loop branch on the same value.
776
- def handoff_report(source, products, checkout, warnings, outcome:, message:)
1179
+ # @param extra [Hash] merged straight onto the report — Phase 3's
1180
+ # `autofill:` (see #webmcp_handoff_report) is the only caller today.
1181
+ def handoff_report(source, products, checkout, warnings, outcome:, message:, extra: {})
777
1182
  handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
778
1183
  checkout_report(source, products, checkout, outcome: outcome, message: message,
779
1184
  warnings: warnings + Array(@pending_handoff_warning),
780
- checkout_url: checkout_url_of(checkout), handoff: handoff)
1185
+ checkout_url: checkout_url_of(checkout), handoff: handoff,
1186
+ **extra)
781
1187
  end
782
1188
 
783
1189
  # docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
@@ -786,10 +1192,18 @@ module Portage
786
1192
  # auto-open/notify exactly like any other hand-off (Phases 1-3 apply
787
1193
  # unchanged), even though it got here because a mode setting chose to
788
1194
  # stop, not because anything was denied or escalated.
789
- def webmcp_handoff_report(source, products, checkout, warnings)
1195
+ #
1196
+ # @param autofill [Hash, nil] docs/plans/webmcp-universal-outbound.md
1197
+ # Phase 3 — #webmcp_handoff_checkout_flow's own autofill attempt
1198
+ # (#attempt_webmcp_autofill), attached as the report's `autofill:`
1199
+ # key when there was one to attach. nil (the default, and always for
1200
+ # #finish_checkout's own force_handoff call) adds no key at all —
1201
+ # the report is byte-for-byte what it was before Phase 3 existed.
1202
+ def webmcp_handoff_report(source, products, checkout, warnings, autofill: nil)
790
1203
  message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
791
1204
  "on the page."
792
- handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
1205
+ extra = autofill ? { autofill: autofill } : {}
1206
+ handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message, extra: extra)
793
1207
  end
794
1208
 
795
1209
  # Never fires on --dry-run (a dry run creates a real checkout but never
@@ -816,20 +1230,98 @@ module Portage
816
1230
  over_cap = precheck_mode? && over_spend_cap?(checkout)
817
1231
  record_pending_handoff(checkout, reason: reason)
818
1232
 
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
-
1233
+ payload = handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
1234
+ warnings: warnings, over_cap: over_cap)
1235
+ dispatch = over_cap ? { target: handoff_target.label, opened: false } : dispatch_to_target(url, payload)
1236
+ error = notifier.call(payload)
1237
+ finalize_handoff(url, dispatch, notified: notifier.enabled? && error.nil?, notify_error: error,
1238
+ over_cap: over_cap)
1239
+ end
1240
+
1241
+ # Same payload for both `--notify-webhook` and `agent:<name>`
1242
+ # (docs/plans/buy-skill-and-local-browser.md Phase 5, decision 3): the
1243
+ # checkout URL plus the approved cart summary — items, qty (each
1244
+ # item's own `quantity`), total (`totals`) and store. Reused as-is
1245
+ # for #handoff_only_report with `checkout: {}` — no cart was ever
1246
+ # built there, so `checkout_id`/`totals`/`items` come back nil/empty,
1247
+ # never fabricated.
826
1248
  def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
827
1249
  payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
828
1250
  query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
829
- totals: checkout["totals"], warnings: warnings }
1251
+ totals: checkout["totals"], items: checkout_items(checkout), warnings: warnings }
830
1252
  over_cap ? payload.merge(over_cap: true) : payload
831
1253
  end
832
1254
 
1255
+ # docs/plans/buy-skill-and-local-browser.md Phase 5 — routes a
1256
+ # hand-off to whichever target `--handoff-target`/PORTAGE_HANDOFF_TARGET/
1257
+ # config.json resolved to. `payload` is only used by the `agent`
1258
+ # branch; building it unconditionally is cheap and keeps this method
1259
+ # a plain dispatch with no branching on whether it's needed.
1260
+ def dispatch_to_target(url, payload)
1261
+ case handoff_target.kind
1262
+ when "print" then { target: "print", opened: false }
1263
+ when "profile" then dispatch_to_profile(url)
1264
+ when "agent" then dispatch_to_agent(payload)
1265
+ else { target: "default", opened: CheckoutHandoff.new(auto_open: @auto_open).call(url) }
1266
+ end
1267
+ end
1268
+
1269
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: when this run
1270
+ # already has a Portage browser profile bridge attached (`Cli.
1271
+ # profile_webmcp_bridge`, only when `--handoff-target profile`
1272
+ # resolved and the profile was running), navigate that same browser
1273
+ # to the checkout URL — the cart was built there too, when the
1274
+ # WebMCP flow ran, or is about to be paid there for the first time,
1275
+ # for a native-UCP store. `#navigate` permits the URL's own host
1276
+ # first (the "plus its checkout host" half of the allowlist), so
1277
+ # this is never blocked by the allowlist itself; it's the one
1278
+ # deliberate Portage-initiated navigation the allowlist always lets
1279
+ # through. Any other bridge failure (the profile process died mid
1280
+ # run, a stale WebSocket) is reported rather than raised, same
1281
+ # posture as every other hand-off dispatch.
1282
+ def dispatch_to_profile(url)
1283
+ unless @webmcp_bridge.respond_to?(:navigate)
1284
+ return { target: "profile", opened: false,
1285
+ message: "No Portage browser profile is attached — run `portage browser profile open` " \
1286
+ "first, then retry with --handoff-target profile." }
1287
+ end
1288
+
1289
+ @webmcp_bridge.navigate(url)
1290
+ { target: "profile", opened: true }
1291
+ rescue StandardError => e
1292
+ { target: "profile", opened: false, message: "Couldn't open the checkout in the Portage browser " \
1293
+ "profile (#{e.message}) — showing the link instead." }
1294
+ end
1295
+
1296
+ def dispatch_to_agent(payload)
1297
+ agent = handoff_agents.lookup(handoff_target.agent_name)
1298
+ unless agent
1299
+ return { target: handoff_target.label, opened: false, agent_delivered: false,
1300
+ agent_error: "#{handoff_target.agent_name.inspect} isn't approved in " \
1301
+ "~/.portage/config.json's handoff_agents — showing the link instead." }
1302
+ end
1303
+
1304
+ error = agent.call(payload)
1305
+ { target: handoff_target.label, opened: false, agent_delivered: error.nil?, agent_error: error }
1306
+ end
1307
+
1308
+ def finalize_handoff(url, dispatch, notified:, notify_error:, over_cap: false)
1309
+ result = { url: url, opened: dispatch[:opened], notified: notified, notify_error: notify_error,
1310
+ handoff_target: dispatch[:target] }
1311
+ result[:agent_delivered] = dispatch[:agent_delivered] if dispatch.key?(:agent_delivered)
1312
+ result[:agent_error] = dispatch[:agent_error] if dispatch.key?(:agent_error)
1313
+ result[:target_message] = dispatch[:message] if dispatch[:message]
1314
+ over_cap ? result.merge(over_cap: true) : result
1315
+ end
1316
+
1317
+ def handoff_target
1318
+ @handoff_target ||= HandoffTarget.new
1319
+ end
1320
+
1321
+ def handoff_agents
1322
+ @handoff_agents ||= HandoffAgents.new
1323
+ end
1324
+
833
1325
  # Never raises: a record that can't be written is a warning on the
834
1326
  # report (surfaced via @pending_handoff_warning, see #handoff_report),
835
1327
  # not a reason to fail the hand-off itself — same posture as
@@ -918,12 +1410,12 @@ module Portage
918
1410
  # is what the checkout actually holds; `products:` is only what the
919
1411
  # search returned, most of which was never bought.
920
1412
  def checkout_report(source, products, checkout, outcome:, message:, checkout_url: nil, handoff: nil,
921
- warnings: [])
1413
+ warnings: [], **extra)
922
1414
  build_report(source: source, outcome: outcome, browse: true, checkout: true, products: products,
923
1415
  message: message, checkout_url: checkout_url, checkout_id: checkout["id"],
924
1416
  checkout_status: checkout["status"], currency: checkout["currency"],
925
1417
  totals: checkout["totals"], items: checkout_items(checkout), handoff: handoff,
926
- warnings: warnings, decisions: @decisions.dup)
1418
+ warnings: warnings, decisions: @decisions.dup, **extra)
927
1419
  end
928
1420
 
929
1421
  def checkout_items(checkout)
@@ -959,7 +1451,7 @@ module Portage
959
1451
  # loopback path ignores it harmlessly, so it's cheapest to always pass
960
1452
  # it rather than branch on which transport `session` happens to be.
961
1453
  def agent_meta
962
- { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
1454
+ { agent_profile: AgentProfileUrl.resolve }
963
1455
  end
964
1456
 
965
1457
  # --- Homepage fetch (used by both the manifest-not-found path and the