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
data/README.md CHANGED
@@ -123,8 +123,9 @@ use the gem's. Or reorder `PATH` so the one you want comes first.
123
123
  ```bash
124
124
  portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
125
125
  [--yes] [--dry-run] [--auto-open|--no-auto-open]
126
- [--notify-webhook URL] [--decision-backend jev|laya]
127
- [--min-confidence N] [--json]
126
+ [--notify-webhook URL]
127
+ [--handoff-target default|print|profile|agent:NAME]
128
+ [--decision-backend jev|laya] [--min-confidence N] [--json]
128
129
  [--wait [--wait-timeout DURATION|off]]
129
130
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
130
131
  portage find --query "..." [--max-price N] [--limit N] [--json]
@@ -145,7 +146,19 @@ portage policy set [--per-transaction-cap N --currency CUR]
145
146
  [--velocity-count N --velocity-window-seconds N]
146
147
  [--allow HOST ...] [--clear-allowlist]
147
148
  portage orders reconcile [--checkout ID] [--json]
148
- portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # aliases: configure, setup
149
+ portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
150
+ portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
151
+ portage index show [--stores|--products] [--json]
152
+ portage index add <url> [--json]
153
+ portage index remove <host> [--json]
154
+ portage index sources [--json]
155
+ portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
156
+ [--history-days 90] [--include-product-pages] [--max-probes 200]
157
+ [--exclude HOST,HOST] [--dry-run] [--yes] [--json]
158
+ portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
159
+ [--url URL (open only)] [--json]
160
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # alias: configure
161
+ portage setup [--json] # interactive wizard on a TTY; --json/no TTY: today's doctor report
149
162
  portage generate adapter NAME [--dir DIR]
150
163
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
151
164
  portage --version
@@ -188,6 +201,8 @@ See "Proxy" below.
188
201
  `PORTAGE_MIN_CONFIDENCE`, then `0.8`. The flag is always checked; the env
189
202
  var is read (and checked) only while a backend is selected, so a stale
190
203
  value can't block a buy that doesn't use the gate.
204
+ - `--handoff-target default|print|profile|agent:NAME` — where a dead-end
205
+ checkout's link goes. See "Hand-off targets and hand-off-only hosts" below.
191
206
  - `--json` — machine-readable report instead of the human-readable summary.
192
207
 
193
208
  Exits `0` when a checkout completed (or a dry-run/browse/search resolved
@@ -346,6 +361,207 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
346
361
  limits bound to one enrolled card) are set via `portage payment enroll
347
362
  --scope-*` above, not here.
348
363
 
364
+ ### Tiers: how a purchase actually finishes
365
+
366
+ Most stores don't let a third-party agent complete payment. `portage buy`
367
+ never pretends otherwise — it builds the cart/checkout it can, then hands
368
+ off through one of three tiers, from least to most involved:
369
+
370
+ | Tier | What | Default | Guardrail |
371
+ | --- | --- | --- | --- |
372
+ | A | Hand off to your own default browser; optionally seed the store index from your bookmarks/history (`portage browser import`) | Hand-off on; import opt-in | Domains only; you see and approve the imported list; nothing leaves the machine |
373
+ | B | A dedicated Portage browser profile drives the cart via WebMCP, then hands off there for you to pay (`portage browser profile`) | Off | Never your default profile; a domain allowlist (the store plus its checkout host); stops at payment — you click pay, your browser's own card autofill fills it |
374
+ | C | Hand-off-only hosts (Amazon, and any host you add) — Portage opens the page or a search/cart-add URL and you buy | On by default, host list is yours to edit | No scraping, no page reads, no UCP probe — just a URL, built not fetched |
375
+
376
+ **Never, in any tier:** Portage reading your browser's password, cookie or
377
+ autofill store; Portage attaching to your default browser profile; Portage
378
+ solving or bypassing a CAPTCHA; card data passing through Portage.
379
+
380
+ ### Hand-off targets and hand-off-only hosts
381
+
382
+ Most checkouts end in a hand-off, not a `purchased` outcome — see the next
383
+ section. `--handoff-target default|print|profile|agent:<name>`
384
+ (`PORTAGE_HANDOFF_TARGET`, or `~/.portage/config.json`'s `"handoff_target"`)
385
+ decides where that link goes:
386
+
387
+ - `default` (Tier A) — opens it in your own browser. Today's behaviour:
388
+ `--auto-open`/`--no-auto-open`, `PORTAGE_AUTO_OPEN_CHECKOUT`.
389
+ - `print` — just reports the URL.
390
+ - `profile` (Tier B) — drives the dedicated Portage browser profile (see
391
+ "Portage browser profile" below) instead of your own. With no profile
392
+ attached (not opened yet, or `portage-ucp-webmcp` isn't installed), it
393
+ reports that and falls back to reporting the link.
394
+ - `agent:<name>` — hands the checkout URL and cart summary (items, qty,
395
+ total, store — the same JSON `--notify-webhook` sends) to an external
396
+ agent you've approved once in `~/.portage/config.json`'s
397
+ `"handoff_agents"` (a command, run with a scrubbed environment and the
398
+ payload on stdin, or an `https` webhook — never invoked unless
399
+ `"approved": true`, and never given credentials, payment tokens or
400
+ shipping details beyond what the checkout URL already holds).
401
+
402
+ An unrecognized value is a usage error (`invalid_option` under `--json`),
403
+ checked before the buy starts.
404
+
405
+ Amazon (every marketplace TLD), walmart.com, ebay.com and bestbuy.com
406
+ (unconditionally — no adapter, no UCP for any of them to opt back into), and
407
+ any host in `~/.portage/config.json`'s `"handoff_only_hosts"` are **Tier C,
408
+ hand-off only**: `portage buy` never sends that host a request at all — no
409
+ UCP probe, no page fetch, no cart — it opens the page (or a cart-add/search
410
+ URL when a product id is known) and you buy it yourself. Absent that config
411
+ key, the default list is every Amazon marketplace; once present, your list
412
+ *is* the list — drop Amazon or add another host, and removing one only
413
+ changes the message, since there's no code here that automates a site
414
+ without UCP or WebMCP. `find`, `index build` and `browser import` all skip
415
+ probing a hand-off-only host too, though they may still list it as a
416
+ candidate you already know about. `portage doctor` reports the current
417
+ target and host list. **Portage is open-source software provided as-is,
418
+ without warranty of any kind (MIT)** — how it's used on any site, and
419
+ compliance with that site's terms, is your own responsibility.
420
+
421
+ ### Categories and routing
422
+
423
+ `portage find` classifies your query and a store's title/description/URL
424
+ slug against `known-stores/categories.yml` (the top two levels of Google's
425
+ published product taxonomy, ~200 nodes shipped in the gem;
426
+ `~/.portage/categories.yml` overrides or extends it). A tagged
427
+ `stores.yml`/index entry (`{url:, categories: [...]}`) only spends one of a
428
+ query's probe slots when its categories actually match — capped at 3 stores
429
+ per category and 12 total — so a large personal allowlist or a big local
430
+ index doesn't crowd out the store that actually sells what you asked for. An
431
+ entry with no matching category is still reached when you name it by host or
432
+ brand.
433
+
434
+ ### Local store index (`portage index`)
435
+
436
+ A fresh install only knows the stores you type into `stores.yml` or that a
437
+ search backend returns for one query. `portage index` gives `find` a
438
+ standing, local list of stores and products to route queries to instead,
439
+ built from sources you can read (`portage index sources`):
440
+
441
+ ```bash
442
+ portage index build # every default source
443
+ portage index build --sources shopify_catalog,stores_file
444
+ portage index build --queries queries.txt # one query per line, instead of the built-in taxonomy sweep
445
+ portage index refresh # re-verify entries older than 7 days, add new ones
446
+ portage index show --stores --json
447
+ portage index show --products --json
448
+ portage index add https://some-shop.example
449
+ portage index remove some-shop.example
450
+ portage index sources # name, what each fetches, source file path
451
+ ```
452
+
453
+ Stored at `~/.portage/index/{stores,products}.json`, **never in git** and
454
+ **never containing a price or stock field** — those are always fetched live.
455
+ Each new origin gets exactly one `/.well-known/ucp` probe (capped at 500 new
456
+ probes per run), throttled, with progress output. Sources:
457
+
458
+ | Source | Fetches | Default |
459
+ | --- | --- | --- |
460
+ | `shopify_catalog` | Merchant origins and product identities, one query per top-level taxonomy node, from `catalog.shopify.com`'s open catalog | on |
461
+ | `stores_file` | Your own `~/.portage/stores.yml` | on |
462
+ | `browser` | Whatever `portage browser import` (below) already saved — this source itself never reads a browser | on, but yields nothing unless you've run `browser import` |
463
+ | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
464
+ | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
465
+
466
+ **The index is untrusted data, on the same footing as any other `find`
467
+ candidate.** It never feeds `Policy#merchant_allowlist` and never counts as
468
+ "you picked a store" for `--yes` — a search ranker (which an index entry
469
+ still is) never gets to complete a purchase on its own.
470
+
471
+ **Known-stores, fetched, not built by you.** The repo itself publishes
472
+ `known-stores/{stores,products}.json` — built the same way, just by the
473
+ maintainer — over jsdelivr's `@main` CDN. `find` uses it automatically
474
+ (cached at `~/.portage/index/known-*.json`, refreshed on `index
475
+ build`/`refresh` or when `doctor` sees it's more than 7 days stale) even
476
+ before you ever run `index build` yourself; your own entries always win over
477
+ it on a conflict. `index build --export DIR` writes a PR-ready copy with
478
+ personal (browser-derived) entries stripped, for anyone who wants to
479
+ contribute a store they found to the shared list.
480
+
481
+ ### Browser import (Tier A)
482
+
483
+ ```bash
484
+ portage browser import --dry-run --json
485
+ portage browser import --browser chrome --history-days 90 --max-probes 200
486
+ portage browser import --yes --exclude some-domain-you-declined.example
487
+ ```
488
+
489
+ Reads your browser's bookmarks and history — Chromium family's `History`
490
+ (SQLite)/`Bookmarks` (JSON), Firefox's `places.sqlite`, Safari's
491
+ `History.db`/`Bookmarks.plist` (needs Full Disk Access on macOS; the command
492
+ explains the prompt and never works around it) — reduces every row to a bare
493
+ domain, and decides each one locally first against the hand-off-only list,
494
+ the local index and the known-stores cache before spending one of at most
495
+ `--max-probes` (default 200) `/.well-known/ucp` probes on an unknown one.
496
+ Kept domains are classified (page titles, bookmark folder names, URL slugs)
497
+ into the same categories `find` routes by, weighted by visit count.
498
+ **Nothing is saved without your approval:** a dry run (or any non-interactive
499
+ run without `--yes`) only shows what it *would* keep; `--yes` (after you've
500
+ reviewed the list, optionally with `--exclude host,host` for ones you don't
501
+ want) writes them to the local index as `sources: ["history"]`/`["bookmark"]`
502
+ entries. `--include-product-pages` (off by default) also keeps product page
503
+ titles/URLs as product entries.
504
+
505
+ **Never reads cookies, saved passwords, or autofill data**, on any browser —
506
+ only the two files named above, verified by a spec that opens a fixture
507
+ profile full of `Login Data`/`Cookies`/`Web Data` decoys and asserts none of
508
+ them were touched.
509
+
510
+ ### Portage browser profile (Tier B)
511
+
512
+ ```bash
513
+ portage browser profile init # create the dedicated profile directory
514
+ portage browser profile open # launch it, remote debugging on
515
+ portage browser profile status
516
+ ```
517
+
518
+ A dedicated Chromium-family profile (Chrome, Edge, Brave or Arc — Firefox
519
+ and Safari aren't supported for driving) under `~/.portage/browser/`,
520
+ launched with remote debugging scoped to *that* profile only — never your
521
+ default one; Chrome 136+ refuses remote debugging on the default profile
522
+ anyway. Sign into your shopping sites there once. With
523
+ `portage buy ... --handoff-target profile`, the cart is built in this same
524
+ browser via WebMCP (when the store supports it) and the checkout opens there
525
+ for you to pay — driving is limited to a domain allowlist (the store being
526
+ bought from, plus its checkout host); navigating anywhere else stops the
527
+ run. Payment is filled by the browser's own saved-card autofill, triggered
528
+ by your own gesture — Portage never touches a payment field and never
529
+ clicks pay. Requires `gem install portage-ucp-webmcp`.
530
+
531
+ ### Retailer offer sources
532
+
533
+ Official, opt-in buyer-side APIs that add more real offers to `portage
534
+ find`, each gated on its own key set in `~/.portage/.env` (or via `portage
535
+ setup`, below):
536
+
537
+ | Retailer | Env var |
538
+ | --- | --- |
539
+ | Walmart Affiliate API | `WALMART_AFFILIATE_API_KEY` |
540
+ | eBay Browse API (Buy It Now only) | `EBAY_BROWSE_ACCESS_TOKEN` (optional `EBAY_MARKETPLACE_ID`) |
541
+ | Best Buy Products API | `BESTBUY_API_KEY` |
542
+ | Etsy Open API v3 (buyer-side `findAllListingsActive`) | `ETSY_LISTINGS_API_KEY` |
543
+ | Amazon Creators API | `AMAZON_CREATORS_ACCESS_TOKEN` (optional `AMAZON_CREATORS_MARKETPLACE`) |
544
+
545
+ With none set, `find` behaves exactly as it did before these existed. Every
546
+ offer from any of these five still ends in hand-off — none of them has a
547
+ checkout `portage buy` can drive, so walmart.com/ebay.com/bestbuy.com are
548
+ hand-off only unconditionally and Amazon/Etsy follow the rules in "Hand-off
549
+ targets and hand-off-only hosts" above (Etsy only when *your own* seller
550
+ credentials aren't already configured for `portage-ucp-etsy`). `portage
551
+ doctor` reports which of the five are active.
552
+
553
+ ### The `portage setup` wizard
554
+
555
+ On a TTY, `portage setup` (or `portage doctor`/`portage configure` when
556
+ nothing is configured yet) walks through setup interactively, one skippable
557
+ step at a time, never echoing a secret back: shipping address, search API
558
+ keys, retailer offer source keys, the agent profile, browser import, index
559
+ build, spending policy caps, and hand-off target/hand-off-only hosts. Each
560
+ step delegates to the real command it configures — nothing is
561
+ reimplemented — so it behaves exactly like running that command yourself.
562
+ Under `--json`, or with no TTY on stdin (piped, CI, or a tool call), `setup`
563
+ never prompts: it prints exactly `doctor --json`'s read-only report.
564
+
349
565
  ### Orders reconcile
350
566
 
351
567
  Nearly every real checkout `portage buy` can't finish itself hands the
@@ -405,8 +621,9 @@ that).
405
621
  ### Doctor
406
622
 
407
623
  ```bash
408
- portage doctor # aliases: portage configure, portage setup
624
+ portage doctor # alias: portage configure
409
625
  portage doctor --json
626
+ portage setup # same report, but interactive on a TTY — see below
410
627
  ```
411
628
 
412
629
  Checks this machine's setup without touching the network (apart from
@@ -427,6 +644,12 @@ installed, then lists anything that needs fixing:
427
644
  - `env_file`: which env file was loaded (see "Environment file" below).
428
645
  Warns when other users can read it.
429
646
  - The confidence gate's backend, the User-Agent, and proxy settings.
647
+ - `index`: local and known-stores index counts and staleness (see "Local
648
+ store index" below).
649
+ - `handoff`: the current `--handoff-target` default and the hand-off-only
650
+ host list, plus the as-is/no-warranty disclaimer.
651
+ - `retailer_offer_sources`: which of the five retailer offer source keys are
652
+ set, and a reminder that none of them can complete a purchase.
430
653
  - Seller-side checks against `Portage::Ucp.configuration` (authenticator,
431
654
  rate limiter, signing keys, payment handlers). These only run when you
432
655
  pass `--require` with your app's initializer (Rails:
@@ -511,6 +734,35 @@ hand-off. `token` isn't implemented yet; it reports
511
734
  `webmcp_token_unsupported` rather than attempting completion. Requires
512
735
  `gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
513
736
 
737
+ Against a page whose tools aren't a known platform preset, `Buy` falls back
738
+ to a schema-matched, shopper-confirmed mapping instead of giving up (see
739
+ `portage-ucp-webmcp`'s README, "Stores that don't run Portage"). A mutating
740
+ match prompts on a real TTY with `--json` off; under `--json` or with no
741
+ TTY it stops instead (outcome `webmcp_mapping_unconfirmed`) and returns the
742
+ proposed mapping for the caller to pass back.
743
+
744
+ `dry_run: true` against a page whose preset hands off through its own
745
+ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
746
+ product search: nothing is added to the store's cart, the tab isn't sent to
747
+ checkout and nothing is autofilled. The `dry_run` report carries a `would:`
748
+ key with the line item, the hand-off tool and whether autofill would run.
749
+
750
+ Once the flow hands off to the store's own checkout page, the shopper can
751
+ opt into having it pre-filled: `--autofill`, or
752
+ `PORTAGE_WEBMCP_AUTOFILL=approve` / config.json's `"webmcp_autofill":
753
+ "approve"` (only that literal string turns it on — a generic truthy value
754
+ doesn't). Even opted in, nothing is typed until a second prompt shows the
755
+ shopper exactly which fields and values are about to be entered and they
756
+ approve it — refused outright under `--json` or no TTY. It only ever
757
+ touches contact email and shipping address (from `PORTAGE_SHIP_*`/the new
758
+ `PORTAGE_SHIP_EMAIL`) plus the cheapest shipping rate it can find; it never
759
+ touches a payment field and never clicks submit/pay, and the run still
760
+ always ends in the same `express_stop` hand-off. A headless browser (or one
761
+ that never says) reports `autofill_needs_headed_browser`; a CAPTCHA/
762
+ challenge on the page reports `autofill_blocked` — see
763
+ `portage-ucp-webmcp`'s README, "Approved autofill of the store's checkout",
764
+ for the full field/outcome list.
765
+
514
766
  ### Decisions
515
767
 
516
768
  `portage buy` and `portage find` make their judgment calls
@@ -542,6 +794,7 @@ with the same value, as `[outcome]`.
542
794
  | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
543
795
  | `low_confidence` | The confidence gate held it. | yes |
544
796
  | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
797
+ | `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
545
798
  | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
546
799
  | `no_match` | Nothing in the store's results matched. | no |
547
800
  | `browse_only` | The store has a catalog but no UCP checkout. | when an adapter offers a link |
@@ -708,7 +961,8 @@ already refuses to commit against a merchant.
708
961
 
709
962
  | Backend | Credentials | Notes |
710
963
  | --- | --- | --- |
711
- | Allowlist | `~/.portage/stores.yml` (YAML array of URLs) or `PORTAGE_STORES` (comma-separated) | Stores you already trust. Checked first, costs no network call. |
964
+ | Allowlist | `~/.portage/stores.yml` (YAML array of URLs, optionally tagged `{url:, categories: [...]}`) or `PORTAGE_STORES` (comma-separated, untagged) | Stores you already trust. Checked first, costs no network call. Tagged entries are routed by category (see "Categories and routing" above); untagged ones are always a candidate. |
965
+ | Index | `~/.portage/index/` (your own `portage index build`) plus the repo's published known-stores list | Ranked between Allowlist and DuckDuckGo. Sits out entirely until an index actually exists — a fresh install's behavior is unchanged. Also matches a query against an indexed *product* by name or GTIN, not just a store. |
712
966
  | DuckDuckGo | none | The [Instant Answer API](https://api.duckduckgo.com/api). Answers *entity* queries, not web queries: `burton snowboards` resolves to burton.com, `snowboard` resolves to nothing. |
713
967
  | Brave | `BRAVE_SEARCH_API_KEY` | Real web results. Set this up if you want open-ended queries to work. |
714
968
  | Google | `GOOGLE_CSE_KEY` + `GOOGLE_CSE_CX` | Programmable Search JSON API. |
@@ -717,6 +971,13 @@ Backends that have no credentials sit out; DuckDuckGo is the keyless default
717
971
  because it's the only no-key engine with a real API, and its narrowness is the
718
972
  price of not scraping.
719
973
 
974
+ Separate from all of the above, `portage find`/`buy` also merge in offers
975
+ directly from `OfferSources` — `ShopifyCatalog` (no key,
976
+ `catalog.shopify.com`'s open catalog, always on) and the retailer offer
977
+ sources (opt-in, keyed — see "Retailer offer sources" above). These skip the
978
+ origin-probe step entirely, since a catalog result already names the
979
+ merchant's own product page.
980
+
720
981
  ### Console
721
982
 
722
983
  `portage-console` is a separate executable — a read-only IRB REPL over the