portage-cli 0.7.5 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +559 -0
  3. data/README.md +266 -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/browser_import/categorize.rb +59 -0
  7. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  8. data/lib/portage/cli/browser_import/domains.rb +47 -0
  9. data/lib/portage/cli/browser_import/filter.rb +91 -0
  10. data/lib/portage/cli/browser_import/importer.rb +248 -0
  11. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  12. data/lib/portage/cli/browser_import/prober.rb +60 -0
  13. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  14. data/lib/portage/cli/browser_import/readers.rb +179 -0
  15. data/lib/portage/cli/browser_import/saver.rb +62 -0
  16. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  17. data/lib/portage/cli/browser_import.rb +23 -0
  18. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  19. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  20. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  21. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  22. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  23. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  24. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  25. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  26. data/lib/portage/cli/browser_profile.rb +25 -0
  27. data/lib/portage/cli/buy.rb +521 -29
  28. data/lib/portage/cli/classifier.rb +158 -0
  29. data/lib/portage/cli/compare.rb +3 -0
  30. data/lib/portage/cli/doctor.rb +155 -1
  31. data/lib/portage/cli/dot_env.rb +55 -0
  32. data/lib/portage/cli/find.rb +96 -12
  33. data/lib/portage/cli/handoff_agents.rb +186 -0
  34. data/lib/portage/cli/handoff_only.rb +94 -0
  35. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  36. data/lib/portage/cli/handoff_target.rb +61 -0
  37. data/lib/portage/cli/index/builder.rb +335 -0
  38. data/lib/portage/cli/index/exporter.rb +91 -0
  39. data/lib/portage/cli/index/known_cache.rb +155 -0
  40. data/lib/portage/cli/index/product_store.rb +101 -0
  41. data/lib/portage/cli/index/sources/browser.rb +31 -0
  42. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  43. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  44. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  45. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  46. data/lib/portage/cli/index/sources.rb +44 -0
  47. data/lib/portage/cli/index/store.rb +109 -0
  48. data/lib/portage/cli/index.rb +20 -0
  49. data/lib/portage/cli/known_stores_url.rb +15 -0
  50. data/lib/portage/cli/offer_sources.rb +460 -0
  51. data/lib/portage/cli/payment_methods.rb +24 -3
  52. data/lib/portage/cli/search_backends.rb +337 -12
  53. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  54. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  55. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  56. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  57. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  58. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  59. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  60. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  61. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  62. data/lib/portage/cli/setup_wizard.rb +74 -0
  63. data/lib/portage/cli/version.rb +1 -1
  64. data/lib/portage/cli/webmcp.rb +10 -3
  65. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  66. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  67. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  68. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  69. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  70. data/lib/portage/cli.rb +525 -5
  71. metadata +58 -2
@@ -0,0 +1,460 @@
1
+ require "uri"
2
+ require "timeout"
3
+ require "portage/ucp/client"
4
+
5
+ require_relative "agent_profile_url"
6
+ require_relative "buyer_context"
7
+ require_relative "catalog_products"
8
+ require_relative "user_agent"
9
+ require_relative "search_backends"
10
+ require_relative "handoff_only"
11
+
12
+ module Portage
13
+ module Cli
14
+ # A second kind of `find` backend, next to SearchBackends: those return
15
+ # bare candidate URLs that still need a `/.well-known/ucp` probe before
16
+ # anything is known about what they stock; an OfferSource answers offers
17
+ # directly, in the same shape Find itself builds
18
+ # (Find#offer — store:/source:/checkout:/product_id:/title:/amount:/
19
+ # currency:/url:), with no probe of its own. Find#call merges the two
20
+ # before Decisions.rank.
21
+ module OfferSources
22
+ # Shopify's catalog needs no key and is always on; the five retailer
23
+ # APIs below (Phase 7, docs/plans/buy-skill-and-local-browser.md) are
24
+ # each opt-in — `#available?` is false until the user sets that
25
+ # source's own key(s) via `portage setup`'s wizard or ~/.portage/.env
26
+ # directly, so a fresh install's `find` behaves exactly as before
27
+ # this phase existed.
28
+ def self.default
29
+ [ShopifyCatalog.new, *retailers.select(&:available?)]
30
+ end
31
+
32
+ def self.retailers
33
+ [WalmartAffiliate.new, EbayBrowse.new, BestBuyProducts.new, EtsyListings.new, AmazonCreators.new]
34
+ end
35
+
36
+ # Every retailer offer source below ends in hand-off — none of them
37
+ # is native UCP, none has a checkout Buy can drive, and walmart.com/
38
+ # ebay.com/bestbuy.com have no adapter this gem ships at all. Buy#
39
+ # handoff_only? checks this alongside HandoffOnly's own (user-
40
+ # editable, Amazon-only) list so a `portage buy` against one of
41
+ # these offers' `store` never fetches the page first — that would
42
+ # just be scraping. Not user-editable, unlike HandoffOnly's list:
43
+ # this isn't a policy choice, it's a direct consequence of shipping
44
+ # a source with zero purchase automation. etsy.com is deliberately
45
+ # left out of this list — portage-ucp-etsy is a *seller*-side
46
+ # adapter, so Buy decides that one itself (see Buy#etsy_buyer_host?)
47
+ # rather than this module overriding a shop owner's own store.
48
+ RETAIL_HANDOFF_HOSTS = %w[walmart.com ebay.com bestbuy.com].freeze
49
+
50
+ def self.retail_handoff_host?(host) = HandoffOnly.matches_any?(host, RETAIL_HANDOFF_HOSTS)
51
+
52
+ # Shared by every retailer source below: an offer's `store` is the
53
+ # origin of the item URL the API itself returned, same idea as
54
+ # ShopifyCatalog#origin_of — kept as one module method instead of
55
+ # four copies.
56
+ def self.origin_of(url)
57
+ uri = URI.parse(url.to_s)
58
+ return nil unless uri.host && uri.scheme.to_s.start_with?("http")
59
+
60
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
61
+ "#{uri.scheme}://#{uri.host}#{port}"
62
+ rescue URI::InvalidURIError
63
+ nil
64
+ end
65
+
66
+ # Shopify's global catalog search (`catalog.shopify.com/api/ucp/mcp`)
67
+ # — answers `search_catalog` anonymously with a correct agent profile,
68
+ # across every merchant on the platform, not just the ones a web
69
+ # search or `stores.yml` named (docs/ucp-tool-gating-investigation.md).
70
+ #
71
+ # Every result's `store` is the *merchant's* origin, not
72
+ # catalog.shopify.com — buying it then goes through the ordinary
73
+ # native-UCP path against that origin, the same as any other offer
74
+ # Find hands to `portage buy`. That means this source counts toward
75
+ # nothing in Find::MAX_PROBES: it never touches a merchant's own
76
+ # `/.well-known/ucp`, and the merchant only gets probed if the caller
77
+ # goes on to buy from it.
78
+ #
79
+ # IDs (live check 2026-09-28, query "hiking boots", GB/GBP): a
80
+ # product's own `id` is a global catalog id (`gid://shopify/p/…`) the
81
+ # merchant itself won't recognise, but each of its `variants[].id` is
82
+ # the merchant's real `gid://shopify/ProductVariant/…`, and
83
+ # `variants[].url` sits on the merchant's own domain. So the offer
84
+ # uses the first variant's id as `product_id` and its URL's origin as
85
+ # `store` — Buy#select_product/#line_item_id_of know how to check that
86
+ # variant out directly, no title re-search needed.
87
+ class ShopifyCatalog
88
+ ENDPOINT = "https://catalog.shopify.com/api/ucp/mcp".freeze
89
+ TIMEOUT = 5
90
+
91
+ # @param agent_profile [String, nil] nil (the default) resolves
92
+ # PORTAGE_AGENT_PROFILE / AgentProfileUrl::DEFAULT at call time,
93
+ # the same as every other caller into a real UCP server.
94
+ def initialize(endpoint: ENDPOINT, agent_profile: nil)
95
+ @endpoint = endpoint
96
+ @agent_profile = agent_profile
97
+ end
98
+
99
+ def name = "shopify_catalog"
100
+
101
+ # One backend being down, rate-limited, or misconfigured shouldn't
102
+ # take the whole `find` with it — same posture as
103
+ # SearchBackends#urls_from. `Timeout.timeout` bounds it the same
104
+ # way, since neither Session nor its HTTP transport takes a
105
+ # deadline of their own.
106
+ # @return [Array<Hash>] the Find#offer shape, one per merchant
107
+ # variant the catalog returned; empty on any failure.
108
+ def offers(query, limit: 10, context: nil)
109
+ products = Timeout.timeout(TIMEOUT) do
110
+ CatalogProducts.from(
111
+ session.search_catalog(query: query, limit: limit, context: context || BuyerContext.from_env,
112
+ meta: agent_meta)
113
+ )
114
+ end
115
+ products.filter_map { |product| offer(product) }
116
+ rescue StandardError
117
+ []
118
+ end
119
+
120
+ private
121
+
122
+ def session
123
+ @session ||= Portage::Ucp::Client.connect(url: @endpoint, headers: UserAgent.headers)
124
+ end
125
+
126
+ def agent_meta
127
+ { agent_profile: @agent_profile || AgentProfileUrl.resolve }
128
+ end
129
+
130
+ def offer(product)
131
+ variant = Array(product["variants"]).first
132
+ origin = variant && origin_of(variant["url"])
133
+ return nil unless origin
134
+
135
+ amount, currency = price_of(product)
136
+ { store: origin, source: name, checkout: nil, product_id: variant["id"], title: product["title"],
137
+ amount: amount, currency: currency, url: variant["url"] }
138
+ end
139
+
140
+ def origin_of(url)
141
+ uri = URI.parse(url.to_s)
142
+ return nil unless uri.host && uri.scheme.to_s.start_with?("http")
143
+
144
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
145
+ "#{uri.scheme}://#{uri.host}#{port}"
146
+ rescue URI::InvalidURIError
147
+ nil
148
+ end
149
+
150
+ # Same wire shape as Find#price_of — a `price_range.min` Money
151
+ # object, wire-hash-keyed same as every result that's passed through
152
+ # Dispatcher#wrap's `to_wire_h`.
153
+ def price_of(product)
154
+ range = product["price_range"]
155
+ return [nil, nil] unless range.is_a?(Hash) && range["min"].is_a?(Hash)
156
+
157
+ [range["min"]["amount"], range["min"]["currency"]]
158
+ end
159
+ end
160
+
161
+ # --- Phase 7: official buyer-side retailer APIs, hand-off only -----
162
+ #
163
+ # None of these gets anywhere near a cart or a checkout — every offer
164
+ # they return has `checkout: false`, and its `store` is one of
165
+ # RETAIL_HANDOFF_HOSTS (or amazon.*, already covered by HandoffOnly),
166
+ # so `portage buy` against it always lands on the existing
167
+ # hand-off-only report rather than trying to drive the site. None of
168
+ # them writes into the local index either: they're not in
169
+ # Index::Sources::DEFAULT_NAMES (or anywhere else `portage index
170
+ # build` looks), which is how "nothing from these goes into
171
+ # products.json/stores.json" is enforced — there's simply no code
172
+ # path that would put one there.
173
+ #
174
+ # Every one of them is opt-in (`#available?` gated on its own env
175
+ # var(s), same posture as SearchBackends::Brave/GoogleCse), uses
176
+ # Portage::Ucp::Support::Connection — via SearchBackends.get_json,
177
+ # never raw Net::HTTP — with the same 5s open/read timeout every
178
+ # other search-shaped call in this gem gets, and swallows its own
179
+ # failures (no key, timeout, non-2xx, unparseable JSON) exactly like
180
+ # ShopifyCatalog above, so one retailer being down or misconfigured
181
+ # never takes `find` down with it.
182
+
183
+ # Walmart's affiliate/product-search API (`api.walmartlabs.com`) —
184
+ # the shape documented for the "Walmart Open API"/"Walmart Affiliate
185
+ # API" family. **Not live-checked** (docs/plans/
186
+ # buy-skill-and-local-browser.md Phase 7 progress log): this session
187
+ # had no API key to verify the endpoint or response shape against;
188
+ # confirm both against Walmart's current affiliate program docs
189
+ # before relying on this in production.
190
+ class WalmartAffiliate
191
+ ENDPOINT = "https://api.walmartlabs.com/v1/search".freeze
192
+ TIMEOUT = 5
193
+ STORE = "https://www.walmart.com".freeze
194
+
195
+ def initialize(api_key: ENV.fetch("WALMART_AFFILIATE_API_KEY", nil))
196
+ @api_key = api_key
197
+ end
198
+
199
+ def name = "walmart_affiliate"
200
+ def available? = !@api_key.to_s.empty?
201
+
202
+ def offers(query, limit: 10, **)
203
+ return [] unless available?
204
+
205
+ data = Timeout.timeout(TIMEOUT) do
206
+ SearchBackends.get_json(URI.parse(ENDPOINT), params: { query: query, apiKey: @api_key, format: "json" })
207
+ end
208
+ Array(data && data["items"]).first(limit).filter_map { |item| offer(item) }
209
+ rescue StandardError
210
+ []
211
+ end
212
+
213
+ private
214
+
215
+ def offer(item)
216
+ url = item["productUrl"]
217
+ return nil if url.to_s.empty?
218
+
219
+ { store: STORE, source: name, checkout: false, product_id: item["itemId"]&.to_s, title: item["name"],
220
+ amount: minor_units(item["salePrice"]), currency: "USD", url: url }
221
+ end
222
+
223
+ def minor_units(price)
224
+ price.is_a?(Numeric) ? (price * 100).round : nil
225
+ end
226
+ end
227
+
228
+ # eBay's Browse API, Buy It Now only (`filter=buyingOptions:
229
+ # {FIXED_PRICE}`, checked again here in code since a caller shouldn't
230
+ # have to trust a query-string filter alone) — deliberately never
231
+ # touches eBay's Order API or its guest checkout, which takes raw
232
+ # card data. `EBAY_BROWSE_ACCESS_TOKEN` is an Application Access
233
+ # Token the user generates themselves (eBay Developer Program,
234
+ # client-credentials grant) — it expires roughly every two hours, so
235
+ # this class doesn't try to refresh it; a stale token just makes
236
+ # `available?` look true but every call fail closed (swallowed, same
237
+ # as any other failure).
238
+ class EbayBrowse
239
+ ENDPOINT = "https://api.ebay.com/buy/browse/v1/item_summary/search".freeze
240
+ TIMEOUT = 5
241
+ FIXED_PRICE = "FIXED_PRICE".freeze
242
+
243
+ def initialize(access_token: ENV.fetch("EBAY_BROWSE_ACCESS_TOKEN", nil),
244
+ marketplace: ENV.fetch("EBAY_MARKETPLACE_ID", "EBAY_US"))
245
+ @access_token = access_token
246
+ @marketplace = marketplace
247
+ end
248
+
249
+ def name = "ebay_browse"
250
+ def available? = !@access_token.to_s.empty?
251
+
252
+ def offers(query, limit: 10, **)
253
+ return [] unless available?
254
+
255
+ data = Timeout.timeout(TIMEOUT) do
256
+ SearchBackends.get_json(
257
+ URI.parse(ENDPOINT),
258
+ params: { q: query, limit: limit, filter: "buyingOptions:{#{FIXED_PRICE}}" },
259
+ headers: { "Authorization" => "Bearer #{@access_token}", "X-EBAY-C-MARKETPLACE-ID" => @marketplace }
260
+ )
261
+ end
262
+ Array(data && data["itemSummaries"]).filter_map { |item| offer(item) }
263
+ rescue StandardError
264
+ []
265
+ end
266
+
267
+ private
268
+
269
+ def offer(item)
270
+ return nil unless Array(item["buyingOptions"]).include?(FIXED_PRICE)
271
+
272
+ url = item["itemWebUrl"]
273
+ origin = url && OfferSources.origin_of(url)
274
+ return nil unless origin
275
+
276
+ price = item["price"] || {}
277
+ { store: origin, source: name, checkout: false, product_id: item["itemId"], title: item["title"],
278
+ amount: minor_units(price["value"]), currency: price["currency"], url: url }
279
+ end
280
+
281
+ def minor_units(value)
282
+ Float(value, exception: false)&.then { |f| (f * 100).round }
283
+ end
284
+ end
285
+
286
+ # Best Buy Products API (`api.bestbuy.com`) — `show=` trims the
287
+ # response to just what an offer needs.
288
+ class BestBuyProducts
289
+ ENDPOINT = "https://api.bestbuy.com/v1/products".freeze
290
+ TIMEOUT = 5
291
+ STORE = "https://www.bestbuy.com".freeze
292
+
293
+ def initialize(api_key: ENV.fetch("BESTBUY_API_KEY", nil))
294
+ @api_key = api_key
295
+ end
296
+
297
+ def name = "bestbuy_products"
298
+ def available? = !@api_key.to_s.empty?
299
+
300
+ def offers(query, limit: 10, **)
301
+ return [] unless available?
302
+
303
+ uri = URI.parse("#{ENDPOINT}(search=#{URI.encode_www_form_component(query)})")
304
+ data = Timeout.timeout(TIMEOUT) do
305
+ SearchBackends.get_json(uri, params: { apiKey: @api_key, format: "json", pageSize: limit,
306
+ show: "sku,name,salePrice,url" })
307
+ end
308
+ Array(data && data["products"]).first(limit).filter_map { |item| offer(item) }
309
+ rescue StandardError
310
+ []
311
+ end
312
+
313
+ private
314
+
315
+ def offer(item)
316
+ url = item["url"]
317
+ return nil if url.to_s.empty?
318
+
319
+ { store: STORE, source: name, checkout: false, product_id: item["sku"]&.to_s, title: item["name"],
320
+ amount: minor_units(item["salePrice"]), currency: "USD", url: url }
321
+ end
322
+
323
+ def minor_units(price)
324
+ price.is_a?(Numeric) ? (price * 100).round : nil
325
+ end
326
+ end
327
+
328
+ # Etsy Open API v3's `findAllListingsActive` — buyer-side, public
329
+ # listing search, needs only an API key (`x-api-key`), no OAuth. This
330
+ # is a different surface from `portage-ucp-etsy`, which is the
331
+ # *seller*-side adapter a shop owner's own ETSY_* credentials drive
332
+ # (see Buy#etsy_buyer_host? for how the two stay out of each other's
333
+ # way at buy time).
334
+ class EtsyListings
335
+ ENDPOINT = "https://openapi.etsy.com/v3/application/listings/active".freeze
336
+ TIMEOUT = 5
337
+
338
+ def initialize(api_key: ENV.fetch("ETSY_LISTINGS_API_KEY", nil))
339
+ @api_key = api_key
340
+ end
341
+
342
+ def name = "etsy_listings"
343
+ def available? = !@api_key.to_s.empty?
344
+
345
+ def offers(query, limit: 10, **)
346
+ return [] unless available?
347
+
348
+ data = Timeout.timeout(TIMEOUT) do
349
+ SearchBackends.get_json(URI.parse(ENDPOINT), params: { keywords: query, limit: limit },
350
+ headers: { "x-api-key" => @api_key })
351
+ end
352
+ Array(data && data["results"]).first(limit).filter_map { |item| offer(item) }
353
+ rescue StandardError
354
+ []
355
+ end
356
+
357
+ private
358
+
359
+ def offer(item)
360
+ url = item["url"] || listing_url(item["listing_id"])
361
+ return nil if url.to_s.empty?
362
+
363
+ amount, currency = price_of(item["price"])
364
+ { store: "https://www.etsy.com", source: name, checkout: false, product_id: item["listing_id"]&.to_s,
365
+ title: item["title"], amount: amount, currency: currency, url: url }
366
+ end
367
+
368
+ def listing_url(id) = id && "https://www.etsy.com/listing/#{id}"
369
+
370
+ # Etsy's Money resource: minor units already, via amount/divisor
371
+ # (amount: 1999, divisor: 100 == $19.99) rather than a decimal.
372
+ def price_of(price)
373
+ return [nil, nil] unless price.is_a?(Hash) && price["amount"].is_a?(Numeric)
374
+
375
+ [price["amount"], price["currency_code"]]
376
+ end
377
+ end
378
+
379
+ # Amazon Creators API — Amazon's Product Advertising API (PA-API 5)
380
+ # is deprecated (retiring 2026-05-15) and no longer onboards new
381
+ # integrations; Creators API is its OAuth2 successor
382
+ # (affiliate-program.amazon.com/creatorsapi/docs, checked
383
+ # 2026-09-28). `AMAZON_CREATORS_ACCESS_TOKEN` is a bearer token the
384
+ # user obtains through that OAuth flow themselves — this class
385
+ # doesn't implement the OAuth dance or token refresh, only the
386
+ # search call, the same "bring your own token" posture as
387
+ # EbayBrowse's Application Access Token above.
388
+ #
389
+ # **Not live-checked, best-effort schema** (docs/plans/
390
+ # buy-skill-and-local-browser.md Phase 7 progress log): Creators
391
+ # API's full request/response reference sits behind an approved
392
+ # Associates account this session doesn't have. The item shape below
393
+ # follows the long-stable PA-API `SearchItems`/`GetItems` resource
394
+ # names (`ASIN`, `DetailPageURL`, `Offers.Listings[0].Price`), which
395
+ # Amazon's own docs describe Creators API as continuing — confirm
396
+ # against the real docs before enabling this in production. Amazon
397
+ # is already Tier C (`HandoffOnly.amazon?`): every offer this
398
+ # returns goes through Buy's existing hand-off-only path the moment
399
+ # anyone tries to buy it, the same as an Amazon URL found any other
400
+ # way, so a schema mismatch here only means a missed offer in
401
+ # `find`, never a purchase-automation risk — a non-matching item is
402
+ # dropped by `#offer`'s own nil guard, not raised.
403
+ class AmazonCreators
404
+ ENDPOINT = "https://creators-api.amazon.com/searchItems".freeze
405
+ TIMEOUT = 5
406
+
407
+ def initialize(access_token: ENV.fetch("AMAZON_CREATORS_ACCESS_TOKEN", nil),
408
+ marketplace: ENV.fetch("AMAZON_CREATORS_MARKETPLACE", "www.amazon.com"))
409
+ @access_token = access_token
410
+ @marketplace = marketplace
411
+ end
412
+
413
+ def name = "amazon_creators"
414
+ def available? = !@access_token.to_s.empty?
415
+
416
+ def offers(query, limit: 10, **)
417
+ return [] unless available?
418
+
419
+ data = Timeout.timeout(TIMEOUT) do
420
+ SearchBackends.get_json(URI.parse(ENDPOINT), params: { keywords: query, marketplace: @marketplace },
421
+ headers: { "Authorization" => "Bearer #{@access_token}",
422
+ "Accept" => "application/json" })
423
+ end
424
+ Array(data && (data["items"] || data["Items"])).first(limit).filter_map { |item| offer(item) }
425
+ rescue StandardError
426
+ []
427
+ end
428
+
429
+ private
430
+
431
+ def offer(item)
432
+ url = item["detailPageUrl"] || item["DetailPageURL"]
433
+ asin = item["asin"] || item["ASIN"]
434
+ return nil if url.to_s.empty? || asin.to_s.empty?
435
+
436
+ amount, currency = price_of(item)
437
+ { store: "https://#{@marketplace}", source: name, checkout: false, product_id: asin,
438
+ title: title_of(item), amount: amount, currency: currency, url: url }
439
+ end
440
+
441
+ def title_of(item) = item["title"] || item.dig("ItemInfo", "Title", "DisplayValue")
442
+
443
+ def price_of(item)
444
+ price = price_field(item)
445
+ return [nil, nil] unless price.is_a?(Hash)
446
+
447
+ amount = price["amount"] || price["Amount"]
448
+ [amount.is_a?(Numeric) ? (amount * 100).round : nil, price["currency"] || price["Currency"]]
449
+ end
450
+
451
+ def price_field(item)
452
+ listing = item.dig("offers", "listings", 0) || item.dig("Offers", "Listings", 0)
453
+ return nil unless listing.is_a?(Hash)
454
+
455
+ listing["price"] || listing["Price"]
456
+ end
457
+ end
458
+ end
459
+ end
460
+ end
@@ -16,6 +16,7 @@ require_relative "payment_methods/keychain_backend"
16
16
  require_relative "payment_methods/secret_service_backend"
17
17
  require_relative "payment_methods/env_backend"
18
18
  require_relative "user_agent"
19
+ require_relative "handoff_only"
19
20
 
20
21
  module Portage
21
22
  module Cli
@@ -60,9 +61,10 @@ module Portage
60
61
 
61
62
  def self.default(path: PATH, backend: detect_backend) = new(path: path, backend: backend).default
62
63
 
63
- def initialize(path: PATH, backend: self.class.detect_backend)
64
+ def initialize(path: PATH, backend: self.class.detect_backend, handoff_only: nil)
64
65
  @path = path
65
66
  @backend = backend
67
+ @handoff_only = handoff_only || HandoffOnly.new
66
68
  end
67
69
 
68
70
  # @return [String, nil] the token `portage buy` should use when
@@ -132,8 +134,10 @@ module Portage
132
134
  #
133
135
  # @return [Hash] {status:, setup_url:, id:, label:} — status is
134
136
  # "complete", "pending" (timed out — the CLI can re-poll later
135
- # against the same enrollment id), or "unsupported" (nothing at
136
- # `url` advertises payment enrollment).
137
+ # against the same enrollment id), "unsupported" (nothing at
138
+ # `url` advertises payment enrollment), or "handoff_only" (a Tier C
139
+ # host, e.g. Amazon — never even probed; docs/plans/
140
+ # buy-skill-and-local-browser.md Phase 5).
137
141
  # @param scope [Hash, nil] Phase 2 per-token policy scope, bound at
138
142
  # enrollment time (docs/plans/agentic-payments.md) — e.g.
139
143
  # `{merchants: ["shop.example.com"], max_amount: 5000, currency: "USD"}`.
@@ -144,6 +148,9 @@ module Portage
144
148
  def enroll(url, label: nil, scope: nil, poll_interval: 3, timeout: 300, sleeper: ->(s) { sleep(s) })
145
149
  raise NotSupportedError, "headless mode has no local storage — set PORTAGE_PAYMENT_TOKEN instead" if headless?
146
150
 
151
+ host = handoff_only_host(url)
152
+ return { status: "handoff_only", host: host } if host
153
+
147
154
  session = discover_session(url)
148
155
  return { status: "unsupported" } unless session && payment_enrollment_advertised?(session)
149
156
 
@@ -196,6 +203,20 @@ module Portage
196
203
 
197
204
  def stringify_keys(hash) = hash.transform_keys(&:to_s)
198
205
 
206
+ # Never even parses far enough to probe a hand-off-only host (Amazon
207
+ # by default) to see whether it supports payment enrollment — there's
208
+ # nothing to set up there regardless of what it answers.
209
+ def handoff_only_host(url)
210
+ uri = normalized_uri(url)
211
+ uri && @handoff_only.host?(uri.host) ? uri.host : nil
212
+ end
213
+
214
+ def normalized_uri(url)
215
+ URI.parse(url.to_s =~ %r{\Ahttps?://}i ? url.to_s : "https://#{url}")
216
+ rescue URI::InvalidURIError
217
+ nil
218
+ end
219
+
199
220
  # Same native-manifest-first, own-store-adapter-fallback discovery as
200
221
  # Buy#call — duplicated rather than extracted since Buy's version is
201
222
  # entangled with cart/checkout-specific branching this only needs the