portage-cli 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. metadata +67 -2
data/README.md CHANGED
@@ -123,13 +123,19 @@ 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]]
130
+ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
+ portage buy --quote QUOTE_ID --yes [--json] ...
129
132
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
130
133
  portage find --query "..." [--max-price N] [--limit N] [--json]
131
134
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
132
135
  [--max-price N] [--json]
136
+ portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
137
+ [--choose REF | --compare REF | --view REF]
138
+ portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
133
139
  portage history [list] [--purchases|--searches] [--limit N] [--json]
134
140
  portage history clear [--purchases|--searches]
135
141
  portage payment list [--json]
@@ -144,8 +150,21 @@ portage policy set [--per-transaction-cap N --currency CUR]
144
150
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
145
151
  [--velocity-count N --velocity-window-seconds N]
146
152
  [--allow HOST ...] [--clear-allowlist]
153
+ [--require-approval person|any|off] (lowering asks at a terminal)
147
154
  portage orders reconcile [--checkout ID] [--json]
148
- portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # aliases: configure, setup
155
+ portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
156
+ portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
157
+ portage index show [--stores|--products] [--json]
158
+ portage index add <url> [--json]
159
+ portage index remove <host> [--json]
160
+ portage index sources [--json]
161
+ portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
162
+ [--history-days 90] [--include-product-pages] [--max-probes 200]
163
+ [--exclude HOST,HOST] [--dry-run] [--yes] [--json]
164
+ portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
165
+ [--url URL (open only)] [--json]
166
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # alias: configure
167
+ portage setup [--json] # interactive wizard on a TTY; --json/no TTY: today's doctor report
149
168
  portage generate adapter NAME [--dir DIR]
150
169
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
151
170
  portage --version
@@ -188,6 +207,8 @@ See "Proxy" below.
188
207
  `PORTAGE_MIN_CONFIDENCE`, then `0.8`. The flag is always checked; the env
189
208
  var is read (and checked) only while a backend is selected, so a stale
190
209
  value can't block a buy that doesn't use the gate.
210
+ - `--handoff-target default|print|profile|agent:NAME` — where a dead-end
211
+ checkout's link goes. See "Hand-off targets and hand-off-only hosts" below.
191
212
  - `--json` — machine-readable report instead of the human-readable summary.
192
213
 
193
214
  Exits `0` when a checkout completed (or a dry-run/browse/search resolved
@@ -346,6 +367,222 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
346
367
  limits bound to one enrolled card) are set via `portage payment enroll
347
368
  --scope-*` above, not here.
348
369
 
370
+ `portage policy set --require-approval person|any|off` (default `any`) sets what a
371
+ real `buy --yes` needs: `off` is `--yes` alone; `any` needs `--quote QUOTE_ID` for a
372
+ quote approved with `portage approve` (by the person, or relayed by an agent with
373
+ `--relayed-yes`); `person` needs the person's own yes at a terminal. Otherwise the run
374
+ is a dry run that returns `needs_approval`. Lowering the level asks for a yes at a
375
+ terminal. Stored as `require_approval` in `~/.portage/policy.json`. It raises the bar
376
+ against an agent but isn't a hard guarantee: a process with a shell can edit that file
377
+ or the quote files in `~/.portage/quotes/`. The whole flow (`find`, `pick`, `buy
378
+ --offer --dry-run`, `approve`, `buy --quote --yes`) is in the
379
+ [CLI JSON reference](../docs/api/cli-json.md) and the
380
+ [tutorial](../docs/cli-usage-tutorial.md#picking-and-approving-at-the-terminal). Upgrade
381
+ note: under the default `any`, `buy --yes` with no approved `--quote` no longer buys;
382
+ restore the old behaviour with `portage policy set --require-approval off` from a
383
+ terminal.
384
+
385
+ ### Tiers: how a purchase actually finishes
386
+
387
+ Most stores don't let a third-party agent complete payment. `portage buy`
388
+ never pretends otherwise — it builds the cart/checkout it can, then hands
389
+ off through one of three tiers, from least to most involved:
390
+
391
+ | Tier | What | Default | Guardrail |
392
+ | --- | --- | --- | --- |
393
+ | 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 |
394
+ | 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 |
395
+ | 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 |
396
+
397
+ **Never, in any tier:** Portage reading your browser's password, cookie or
398
+ autofill store; Portage attaching to your default browser profile; Portage
399
+ solving or bypassing a CAPTCHA; card data passing through Portage.
400
+
401
+ ### Hand-off targets and hand-off-only hosts
402
+
403
+ Most checkouts end in a hand-off, not a `purchased` outcome — see the next
404
+ section. `--handoff-target default|print|profile|agent:<name>`
405
+ (`PORTAGE_HANDOFF_TARGET`, or `~/.portage/config.json`'s `"handoff_target"`)
406
+ decides where that link goes:
407
+
408
+ - `default` (Tier A) — opens it in your own browser. Today's behaviour:
409
+ `--auto-open`/`--no-auto-open`, `PORTAGE_AUTO_OPEN_CHECKOUT`.
410
+ - `print` — just reports the URL.
411
+ - `profile` (Tier B) — drives the dedicated Portage browser profile (see
412
+ "Portage browser profile" below) instead of your own. With no profile
413
+ attached (not opened yet, or `portage-ucp-webmcp` isn't installed), it
414
+ reports that and falls back to reporting the link.
415
+ - `agent:<name>` — hands the checkout URL and cart summary (items, qty,
416
+ total, store — the same JSON `--notify-webhook` sends) to an external
417
+ agent you've approved once in `~/.portage/config.json`'s
418
+ `"handoff_agents"` (a command, run with a scrubbed environment and the
419
+ payload on stdin, or an `https` webhook — never invoked unless
420
+ `"approved": true`, and never given credentials, payment tokens or
421
+ shipping details beyond what the checkout URL already holds).
422
+
423
+ An unrecognized value is a usage error (`invalid_option` under `--json`),
424
+ checked before the buy starts.
425
+
426
+ Amazon (every marketplace TLD), walmart.com, ebay.com and bestbuy.com
427
+ (unconditionally — no adapter, no UCP for any of them to opt back into), and
428
+ any host in `~/.portage/config.json`'s `"handoff_only_hosts"` are **Tier C,
429
+ hand-off only**: `portage buy` never sends that host a request at all — no
430
+ UCP probe, no page fetch, no cart — it opens the page (or a cart-add/search
431
+ URL when a product id is known) and you buy it yourself. Absent that config
432
+ key, the default list is every Amazon marketplace; once present, your list
433
+ *is* the list — drop Amazon or add another host, and removing one only
434
+ changes the message, since there's no code here that automates a site
435
+ without UCP or WebMCP. `find`, `index build` and `browser import` all skip
436
+ probing a hand-off-only host too, though they may still list it as a
437
+ candidate you already know about. `portage doctor` reports the current
438
+ target and host list. **Portage is open-source software provided as-is,
439
+ without warranty of any kind (MIT)** — how it's used on any site, and
440
+ compliance with that site's terms, is your own responsibility.
441
+
442
+ ### Categories and routing
443
+
444
+ `portage find` classifies your query and a store's title/description/URL
445
+ slug against `known-stores/categories.yml` (the top two levels of Google's
446
+ published product taxonomy, ~200 nodes shipped in the gem;
447
+ `~/.portage/categories.yml` overrides or extends it). A tagged
448
+ `stores.yml`/index entry (`{url:, categories: [...]}`) only spends one of a
449
+ query's probe slots when its categories actually match — capped at 3 stores
450
+ per category and 12 total — so a large personal allowlist or a big local
451
+ index doesn't crowd out the store that actually sells what you asked for. An
452
+ entry with no matching category is still reached when you name it by host or
453
+ brand.
454
+
455
+ ### Local store index (`portage index`)
456
+
457
+ A fresh install only knows the stores you type into `stores.yml` or that a
458
+ search backend returns for one query. `portage index` gives `find` a
459
+ standing, local list of stores and products to route queries to instead,
460
+ built from sources you can read (`portage index sources`):
461
+
462
+ ```bash
463
+ portage index build # every default source
464
+ portage index build --sources shopify_catalog,stores_file
465
+ portage index build --queries queries.txt # one query per line, instead of the built-in taxonomy sweep
466
+ portage index refresh # re-verify entries older than 7 days, add new ones
467
+ portage index show --stores --json
468
+ portage index show --products --json
469
+ portage index add https://some-shop.example
470
+ portage index remove some-shop.example
471
+ portage index sources # name, what each fetches, source file path
472
+ ```
473
+
474
+ Stored at `~/.portage/index/{stores,products}.json`, **never in git** and
475
+ **never containing a price or stock field** — those are always fetched live.
476
+ Each new origin gets exactly one `/.well-known/ucp` probe (capped at 500 new
477
+ probes per run), throttled, with progress output. Sources:
478
+
479
+ | Source | Fetches | Default |
480
+ | --- | --- | --- |
481
+ | `shopify_catalog` | Merchant origins and product identities, one query per top-level taxonomy node, from `catalog.shopify.com`'s open catalog | on |
482
+ | `stores_file` | Your own `~/.portage/stores.yml` | on |
483
+ | `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` |
484
+ | `wikidata` | Retailers'/brands' official sites via a public SPARQL query | opt-in (`--sources wikidata`) |
485
+ | `webmcp_sweep` | Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
486
+
487
+ **The index is untrusted data, on the same footing as any other `find`
488
+ candidate.** It never feeds `Policy#merchant_allowlist` and never counts as
489
+ "you picked a store" for `--yes` — a search ranker (which an index entry
490
+ still is) never gets to complete a purchase on its own.
491
+
492
+ **Known-stores, fetched, not built by you.** The repo itself publishes
493
+ `known-stores/{stores,products}.json` — built the same way, just by the
494
+ maintainer — over jsdelivr's `@main` CDN. `find` uses it automatically
495
+ (cached at `~/.portage/index/known-*.json`, refreshed on `index
496
+ build`/`refresh` or when `doctor` sees it's more than 7 days stale) even
497
+ before you ever run `index build` yourself; your own entries always win over
498
+ it on a conflict. `index build --export DIR` writes a PR-ready copy with
499
+ personal (browser-derived) entries stripped, for anyone who wants to
500
+ contribute a store they found to the shared list.
501
+
502
+ ### Browser import (Tier A)
503
+
504
+ ```bash
505
+ portage browser import --dry-run --json
506
+ portage browser import --browser chrome --history-days 90 --max-probes 200
507
+ portage browser import --yes --exclude some-domain-you-declined.example
508
+ ```
509
+
510
+ Reads your browser's bookmarks and history — Chromium family's `History`
511
+ (SQLite)/`Bookmarks` (JSON), Firefox's `places.sqlite`, Safari's
512
+ `History.db`/`Bookmarks.plist` (needs Full Disk Access on macOS; the command
513
+ explains the prompt and never works around it) — reduces every row to a bare
514
+ domain, and decides each one locally first against the hand-off-only list,
515
+ the local index and the known-stores cache before spending one of at most
516
+ `--max-probes` (default 200) `/.well-known/ucp` probes on an unknown one.
517
+ Kept domains are classified (page titles, bookmark folder names, URL slugs)
518
+ into the same categories `find` routes by, weighted by visit count.
519
+ **Nothing is saved without your approval:** a dry run (or any non-interactive
520
+ run without `--yes`) only shows what it *would* keep; `--yes` (after you've
521
+ reviewed the list, optionally with `--exclude host,host` for ones you don't
522
+ want) writes them to the local index as `sources: ["history"]`/`["bookmark"]`
523
+ entries. `--include-product-pages` (off by default) also keeps product page
524
+ titles/URLs as product entries.
525
+
526
+ **Never reads cookies, saved passwords, or autofill data**, on any browser —
527
+ only the two files named above, verified by a spec that opens a fixture
528
+ profile full of `Login Data`/`Cookies`/`Web Data` decoys and asserts none of
529
+ them were touched.
530
+
531
+ ### Portage browser profile (Tier B)
532
+
533
+ ```bash
534
+ portage browser profile init # create the dedicated profile directory
535
+ portage browser profile open # launch it, remote debugging on
536
+ portage browser profile status
537
+ ```
538
+
539
+ A dedicated Chromium-family profile (Chrome, Edge, Brave or Arc — Firefox
540
+ and Safari aren't supported for driving) under `~/.portage/browser/`,
541
+ launched with remote debugging scoped to *that* profile only — never your
542
+ default one; Chrome 136+ refuses remote debugging on the default profile
543
+ anyway. Sign into your shopping sites there once. With
544
+ `portage buy ... --handoff-target profile`, the cart is built in this same
545
+ browser via WebMCP (when the store supports it) and the checkout opens there
546
+ for you to pay — driving is limited to a domain allowlist (the store being
547
+ bought from, plus its checkout host); navigating anywhere else stops the
548
+ run. Payment is filled by the browser's own saved-card autofill, triggered
549
+ by your own gesture — Portage never touches a payment field and never
550
+ clicks pay. Requires `gem install portage-ucp-webmcp`.
551
+
552
+ ### Retailer offer sources
553
+
554
+ Official, opt-in buyer-side APIs that add more real offers to `portage
555
+ find`, each gated on its own key set in `~/.portage/.env` (or via `portage
556
+ setup`, below):
557
+
558
+ | Retailer | Env var |
559
+ | --- | --- |
560
+ | Walmart Affiliate API | `WALMART_AFFILIATE_API_KEY` |
561
+ | eBay Browse API (Buy It Now only) | `EBAY_BROWSE_ACCESS_TOKEN` (optional `EBAY_MARKETPLACE_ID`) |
562
+ | Best Buy Products API | `BESTBUY_API_KEY` |
563
+ | Etsy Open API v3 (buyer-side `findAllListingsActive`) | `ETSY_LISTINGS_API_KEY` |
564
+ | Amazon Creators API | `AMAZON_CREATORS_ACCESS_TOKEN` (optional `AMAZON_CREATORS_MARKETPLACE`) |
565
+
566
+ With none set, `find` behaves exactly as it did before these existed. Every
567
+ offer from any of these five still ends in hand-off — none of them has a
568
+ checkout `portage buy` can drive, so walmart.com/ebay.com/bestbuy.com are
569
+ hand-off only unconditionally and Amazon/Etsy follow the rules in "Hand-off
570
+ targets and hand-off-only hosts" above (Etsy only when *your own* seller
571
+ credentials aren't already configured for `portage-ucp-etsy`). `portage
572
+ doctor` reports which of the five are active.
573
+
574
+ ### The `portage setup` wizard
575
+
576
+ On a TTY, `portage setup` (or `portage doctor`/`portage configure` when
577
+ nothing is configured yet) walks through setup interactively, one skippable
578
+ step at a time, never echoing a secret back: shipping address, search API
579
+ keys, retailer offer source keys, the agent profile, browser import, index
580
+ build, spending policy caps, and hand-off target/hand-off-only hosts. Each
581
+ step delegates to the real command it configures — nothing is
582
+ reimplemented — so it behaves exactly like running that command yourself.
583
+ Under `--json`, or with no TTY on stdin (piped, CI, or a tool call), `setup`
584
+ never prompts: it prints exactly `doctor --json`'s read-only report.
585
+
349
586
  ### Orders reconcile
350
587
 
351
588
  Nearly every real checkout `portage buy` can't finish itself hands the
@@ -405,8 +642,9 @@ that).
405
642
  ### Doctor
406
643
 
407
644
  ```bash
408
- portage doctor # aliases: portage configure, portage setup
645
+ portage doctor # alias: portage configure
409
646
  portage doctor --json
647
+ portage setup # same report, but interactive on a TTY — see below
410
648
  ```
411
649
 
412
650
  Checks this machine's setup without touching the network (apart from
@@ -427,6 +665,12 @@ installed, then lists anything that needs fixing:
427
665
  - `env_file`: which env file was loaded (see "Environment file" below).
428
666
  Warns when other users can read it.
429
667
  - The confidence gate's backend, the User-Agent, and proxy settings.
668
+ - `index`: local and known-stores index counts and staleness (see "Local
669
+ store index" below).
670
+ - `handoff`: the current `--handoff-target` default and the hand-off-only
671
+ host list, plus the as-is/no-warranty disclaimer.
672
+ - `retailer_offer_sources`: which of the five retailer offer source keys are
673
+ set, and a reminder that none of them can complete a purchase.
430
674
  - Seller-side checks against `Portage::Ucp.configuration` (authenticator,
431
675
  rate limiter, signing keys, payment handlers). These only run when you
432
676
  pass `--require` with your app's initializer (Rails:
@@ -511,6 +755,50 @@ hand-off. `token` isn't implemented yet; it reports
511
755
  `webmcp_token_unsupported` rather than attempting completion. Requires
512
756
  `gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
513
757
 
758
+ Against a page whose tools aren't a known platform preset, `Buy` falls back
759
+ to a schema-matched, shopper-confirmed mapping instead of giving up (see
760
+ `portage-ucp-webmcp`'s README, "Stores that don't run Portage"). A mutating
761
+ match prompts on a real TTY with `--json` off. Under `--json`, or with no
762
+ TTY, it stops instead: outcome `webmcp_mapping_unconfirmed`, with the
763
+ proposal in `tool_names_proposal`.
764
+
765
+ No flag passes a mapping back. From the CLI, re-run the same command in
766
+ your own terminal without `--json` and answer the prompt. `--dry-run` is
767
+ enough, because the mapping is confirmed before the dry-run check. The
768
+ approved mapping is saved to `~/.portage/webmcp_mappings.json`, and later
769
+ runs reuse it with no prompt.
770
+
771
+ `Buy` has no `tool_names:` keyword either. A library caller has two hooks.
772
+ `webmcp_mapping_confirm:` takes any object whose `call(proposal, tools)`
773
+ returns a `tool_names:` hash, or nil to stop with
774
+ `webmcp_mapping_unconfirmed`. `Buy` saves whatever hash it returns to
775
+ `webmcp_mappings:`, the store approved mappings are read from (default: a
776
+ `Portage::Cli::WebmcpMappings` on `~/.portage/webmcp_mappings.json`).
777
+ Outside `Buy`, pass `tool_names:` to `Portage::Ucp::WebMcp.connect`
778
+ yourself.
779
+
780
+ `dry_run: true` against a page whose preset hands off through its own
781
+ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
782
+ product search: nothing is added to the store's cart, the tab isn't sent to
783
+ checkout and nothing is autofilled. The `dry_run` report carries a `would:`
784
+ key with the line item, the hand-off tool and whether autofill would run.
785
+
786
+ Once the flow hands off to the store's own checkout page, the shopper can
787
+ opt into having it pre-filled: `--autofill`, or
788
+ `PORTAGE_WEBMCP_AUTOFILL=approve` / config.json's `"webmcp_autofill":
789
+ "approve"` (only that literal string turns it on — a generic truthy value
790
+ doesn't). Even opted in, nothing is typed until a second prompt shows the
791
+ shopper exactly which fields and values are about to be entered and they
792
+ approve it — refused outright under `--json` or no TTY. It only ever
793
+ touches contact email and shipping address (from `PORTAGE_SHIP_*`/the new
794
+ `PORTAGE_SHIP_EMAIL`) plus the cheapest shipping rate it can find; it never
795
+ touches a payment field and never clicks submit/pay, and the run still
796
+ always ends in the same `express_stop` hand-off. A headless browser (or one
797
+ that never says) reports `autofill_needs_headed_browser`; a CAPTCHA/
798
+ challenge on the page reports `autofill_blocked` — see
799
+ `portage-ucp-webmcp`'s README, "Approved autofill of the store's checkout",
800
+ for the full field/outcome list.
801
+
514
802
  ### Decisions
515
803
 
516
804
  `portage buy` and `portage find` make their judgment calls
@@ -542,6 +830,7 @@ with the same value, as `[outcome]`.
542
830
  | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
543
831
  | `low_confidence` | The confidence gate held it. | yes |
544
832
  | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
833
+ | `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
834
  | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
546
835
  | `no_match` | Nothing in the store's results matched. | no |
547
836
  | `browse_only` | The store has a catalog but no UCP checkout. | when an adapter offers a link |
@@ -708,7 +997,8 @@ already refuses to commit against a merchant.
708
997
 
709
998
  | Backend | Credentials | Notes |
710
999
  | --- | --- | --- |
711
- | Allowlist | `~/.portage/stores.yml` (YAML array of URLs) or `PORTAGE_STORES` (comma-separated) | Stores you already trust. Checked first, costs no network call. |
1000
+ | 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. |
1001
+ | 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
1002
  | 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
1003
  | Brave | `BRAVE_SEARCH_API_KEY` | Real web results. Set this up if you want open-ended queries to work. |
714
1004
  | Google | `GOOGLE_CSE_KEY` + `GOOGLE_CSE_CX` | Programmable Search JSON API. |
@@ -717,6 +1007,13 @@ Backends that have no credentials sit out; DuckDuckGo is the keyless default
717
1007
  because it's the only no-key engine with a real API, and its narrowness is the
718
1008
  price of not scraping.
719
1009
 
1010
+ Separate from all of the above, `portage find`/`buy` also merge in offers
1011
+ directly from `OfferSources` — `ShopifyCatalog` (no key,
1012
+ `catalog.shopify.com`'s open catalog, always on) and the retailer offer
1013
+ sources (opt-in, keyed — see "Retailer offer sources" above). These skip the
1014
+ origin-probe step entirely, since a catalog result already names the
1015
+ merchant's own product page.
1016
+
720
1017
  ### Console
721
1018
 
722
1019
  `portage-console` is a separate executable — a read-only IRB REPL over the