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
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: a682278ec8b34cb6e2cd649f345cb6779a7cec5d0bc22e6950721f7996a2d877
4
- data.tar.gz: ed490b87284e6a8c9bd9ab280ea600d16877820240386d34e239309a8204b16d
3
+ metadata.gz: 3d34c9ba25977baefa9f1f802e38ae6b2175e455d246f7a0dd3a2833136c46e8
4
+ data.tar.gz: 2c1d701bafd9c914115bdb02ab661906eaddba139e0627a56bfa0cf094e71d06
5
5
  SHA512:
6
- metadata.gz: c22fd29667f7281c0173503cc9797158de1cd9d494cfcc98795bbce3ecfbaa03ed67ecb1321b3adf83000cf251168a62118db703bf1fb7b5417ba615a672b8c5
7
- data.tar.gz: 42016dd1b716169b2dbaadef6f83a210ab8750584b23464e8c38c76aa39374988eeda97cfdc133c3edffaaf3c3ef8b34a76aa2fd57a9c96ad32292d529878df4
6
+ metadata.gz: ec3b6bf08d832abbe197591050b229f38a3650603feb6e94b47a6e84fbb9d3b9ddf531c51e84d71c8b708131b655e6b501e6a55662908eb6edb60981da4f14a2
7
+ data.tar.gz: 137b718fd1283383aa70879a6f2290a35dc1bc3f3a898ceff6965019b110329caf943dcf3ef39c04c29d1661beee3da6edd3a056877b7a6c3a932debce90c99f
data/CHANGELOG.md CHANGED
@@ -4,6 +4,622 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [Unreleased]
8
+
9
+ ## [0.9.0] - 2026-09-29
10
+
11
+ - **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
12
+ Loop steps 3 and 5 now have a ready-made interface for a person at a terminal
13
+ and for an agent relaying their answer.
14
+ - **Offer refs.** Each `find` offer carries an `offer_ref` (`of_` and 6 hex
15
+ digits), and the report a `search_id` (`se_` and 8 hex digits). Both are saved
16
+ with the search in history, so `history --json` searches now hold `search_id`
17
+ and `offers[]`. `compare` saves its offers with refs and a `search_id` too.
18
+ `portage buy --offer REF` takes the store, product and query from the saved
19
+ offer; an unknown ref is `offer_not_found`.
20
+ - **Quotes.** A `buy --dry-run` that priced a checkout saves a quote in
21
+ `~/.portage/quotes/` and reports `quote_id`. `buy --quote QUOTE_ID --yes`
22
+ buys exactly that quote, capped at the quoted total: if the real checkout costs
23
+ more (or is in another currency) it refuses with `quote_changed`, carrying
24
+ `quoted_total` and `current_total`, without charging or handing off. Quotes don't
25
+ expire and are used once (`purchased` or any hand-off). `quote_not_found` and
26
+ `quote_used` cover the rest.
27
+ - **`portage pick`.** Loop step 3. `needs_pick` returns `choices[]` (`ref`, `label`,
28
+ `url`, plus the offer's fields) and a last "Compare an offer across stores"
29
+ choice; `--choose REF` relays an answer (`picked`, `by: "agent_relayed"`),
30
+ `--compare REF` runs compare and shows the pick again over its results,
31
+ `--search LAST|SEARCH_ID` picks the search. At a terminal it asks on `/dev/tty`
32
+ (`by: "person"`).
33
+ - **`portage approve QUOTE_ID`.** Loop step 5. `needs_approval` returns a
34
+ `summary` (title, store, qty, total, `total_display`, `url`); `--relayed-yes`
35
+ records `approved_by: "agent_relayed"`; a yes typed at a terminal records
36
+ `"person"`. The quote also keeps `approved`, `approved_by` and `approved_at`.
37
+ - **`--via auto|tty|agent`.** `tty` asks on `/dev/tty` (so it works with stdout
38
+ piped) and returns `no_terminal` when there's none; `agent` asks nobody;
39
+ `auto` is `tty` with a terminal and no `--json`, else `agent`.
40
+ - **`--view REF` / `approve --view` and `v N` / `v` at a prompt** open the product
41
+ page and never count as an answer. Only an `http(s)` URL on the offer's own
42
+ store host is opened; anything else is `view_refused`. `pick`'s compare
43
+ choice uses the proxy settings from env and config (there are no `--proxy`
44
+ flags on `pick`).
45
+ - **`policy set --require-approval person|any|off`** (default `any`, stored as
46
+ `require_approval` in `~/.portage/policy.json`, always shown by `policy show`).
47
+ Lowering it needs a yes typed at a terminal. It raises the bar against an agent
48
+ but isn't a hard guarantee: a process with a shell can edit the policy or quote
49
+ files. Docs: `docs/api/cli-json.md`, `docs/agentic-flow.md`,
50
+ `docs/cli-usage-tutorial.md`.
51
+ - **Upgrade note.** Under the default `any`, a `buy --yes` without an approved
52
+ `--quote` no longer buys: it dry-runs and returns `needs_approval` (exit `0`).
53
+ Restore the old behaviour with `portage policy set --require-approval off`
54
+ from a terminal. `buy --query`'s numbered pick now asks on `/dev/tty` too.
55
+
56
+ - Documentation only, no code change. The README said a
57
+ `webmcp_mapping_unconfirmed` report returns the proposed mapping "for the
58
+ caller to pass back". No flag takes a mapping back, and `Buy` has no
59
+ `tool_names:` keyword. The README now says what each caller can do. From
60
+ the CLI, re-run the command in a real terminal without `--json`
61
+ (`--dry-run` is enough) and answer the prompt; the approved mapping is
62
+ saved to `~/.portage/webmcp_mappings.json`. From Ruby, inject
63
+ `webmcp_mapping_confirm:` or `webmcp_mappings:`, or pass `tool_names:` to
64
+ `WebMcp.connect` yourself.
65
+
66
+ ## [0.8.0] - 2026-09-29
67
+
68
+ - **Requires `portage-ucp-webmcp` 0.2.0 or newer for WebMCP paths.**
69
+ `Webmcp.available?` now treats an older install as not installed
70
+ (`Webmcp::MIN_VERSION`), since `WebMcp::Autofill`, `Presets`, `Matcher`
71
+ and `Fingerprint` first shipped in 0.2.0. With 0.1.1 installed, the
72
+ profile hand-off falls back to showing the link instead of raising a
73
+ `NameError`.
74
+
75
+ - **`find --max-price` now filters offer-source offers too.** Offers from
76
+ `OfferSources` (`ShopifyCatalog`, the retailer APIs) skipped the
77
+ `--max-price` check that probed stores' offers go through, so a
78
+ `--max-price 150` search could list a £190 catalog offer. Unpriced offers
79
+ still stay in, as before. `find`'s summary also counts stores from the
80
+ offers themselves ("Found 1 offer(s) across 1 store(s)."), not just the
81
+ probed ones, which reported "0 UCP store(s)" for catalog-only results.
82
+ Both found by the clean-session `/buy` check.
83
+ - **Docs release for Phases 1-7** (`docs/plans/buy-skill-and-local-browser.md`
84
+ Phase 8). `README.md` here gains full documentation of everything the
85
+ above entries shipped that wasn't written up yet: the usage banner now
86
+ lists `index`, `browser import`, `browser profile` and `setup`; new
87
+ sections cover Tiers A/B/C, `--handoff-target`/hand-off-only hosts (now
88
+ reflecting Phase 6's built `profile` target and Phase 7's retail hosts),
89
+ categories/routing, the local store index and its sources, browser
90
+ import, the Portage browser profile, the five retailer offer source env
91
+ vars, and the `setup` wizard's step order; the Search backends table
92
+ gains the `Index` backend and a note on `OfferSources` merging offers
93
+ directly. `Doctor`'s bullet list documents the `index`/`handoff`/
94
+ `retailer_offer_sources` findings added in Phases 2b/5/7. No code
95
+ changed.
96
+ - **Retailer offer sources, hand-off only** (`docs/plans/
97
+ buy-skill-and-local-browser.md` Phase 7). `Portage::Cli::OfferSources`
98
+ gains five official, opt-in buyer-side retailer APIs alongside
99
+ `ShopifyCatalog`: `WalmartAffiliate`, `EbayBrowse` (Buy It Now only —
100
+ `filter=buyingOptions:{FIXED_PRICE}`, checked again in code, and never
101
+ eBay's Order API/guest checkout, which takes raw card data),
102
+ `BestBuyProducts`, `EtsyListings` (Etsy Open API v3's
103
+ `findAllListingsActive`, the *buyer*-side surface — `portage-ucp-etsy`
104
+ stays the seller-side adapter, untouched) and `AmazonCreators`. Each is
105
+ gated on its own key/token env var (`WALMART_AFFILIATE_API_KEY`,
106
+ `EBAY_BROWSE_ACCESS_TOKEN`, `BESTBUY_API_KEY`, `ETSY_LISTINGS_API_KEY`,
107
+ `AMAZON_CREATORS_ACCESS_TOKEN`), `#available?` false and silently
108
+ excluded from `OfferSources.default` with none set — a fresh install's
109
+ `find` is unchanged. Every request goes through
110
+ `Portage::Ucp::Support::Connection` (via `SearchBackends.get_json`,
111
+ never raw `Net::HTTP`), 5s timeout, failures swallowed exactly like
112
+ `ShopifyCatalog`. None of the five is wired into `Index::Sources`, so
113
+ nothing they return is ever written to `~/.portage/index/
114
+ {stores,products}.json` — the plan's "honour each API's caching terms,
115
+ default to not persisting" is enforced by simply having no code path
116
+ that would.
117
+ **Amazon PA-API vs. Creators API (live web search, 2026-09-28):**
118
+ PA-API 5 is deprecated, retiring 2026-05-15 (already past), no longer
119
+ onboarding new integrations; Creators API is its OAuth2 successor.
120
+ `AmazonCreators` targets Creators API's search endpoint with a
121
+ user-supplied bearer token (no OAuth dance or refresh implemented here,
122
+ same "bring your own token" posture `EbayBrowse` uses for eBay's
123
+ Application Access Token) and reads the long-stable PA-API
124
+ `SearchItems`/`GetItems` item shape (`ASIN`/`DetailPageURL`/
125
+ `Offers.Listings[0].Price`), which Amazon's own docs describe Creators
126
+ API as continuing — **not live-checked**, no Associates account
127
+ available this session; a schema mismatch only drops an offer
128
+ (`#offer`'s nil guard), never a purchase-automation risk, since Amazon
129
+ is already Tier C.
130
+ Every offer ends in hand-off, never a completed purchase. Amazon
131
+ already routed through the existing Tier C `HandoffOnly` path
132
+ unchanged. `Buy#handoff_only?` now also checks
133
+ `OfferSources.retail_handoff_host?` (walmart.com/ebay.com/bestbuy.com,
134
+ unconditional — no adapter, no UCP, not a user-editable policy choice)
135
+ and a new `#etsy_buyer_host?` (etsy.com, *unless* the process already
136
+ has its own `ETSY_ACCESS_TOKEN`/`ETSY_API_KEY`/`ETSY_SHOP_ID` set, in
137
+ which case the existing `portage-ucp-etsy` seller-adapter flow still
138
+ applies unchanged) — every one of these hosts is checked before a
139
+ single UCP probe or homepage fetch, same "never even scraped" guarantee
140
+ Amazon already had. `handoff_only_checkout_url` returns the exact URL
141
+ passed to `portage buy` for these hosts (the real product page a
142
+ retailer offer source found), rather than falling back to the origin
143
+ homepage the way an unknown Tier C host still does.
144
+ `portage setup` gains an eighth, opt-in wizard step (`Steps::
145
+ RetailerKeys`, between search keys and the agent profile) for the five
146
+ keys, written to `~/.portage/.env`, never echoed back. `portage doctor`
147
+ gains a `retailer_offer_sources` finding (always info) naming which are
148
+ active and restating that none of them can complete a purchase.
149
+ **Open question 3 resolved:** kept in `portage-cli`'s `OfferSources`
150
+ (one class per retailer, one file), not split into per-retailer gems or
151
+ a `portage-ucp-retail` gem — Phase 1's seam (`OfferSource#offers`) is a
152
+ small interface with no adapter-style `Client`/`Adapter` pair to split
153
+ out, each source is ~50-70 lines with no shared state beyond
154
+ `OfferSources.origin_of`, none of them completes a purchase (the usual
155
+ reason an adapter earns its own gem — its own `Client`/credentials/
156
+ fulfillment logic), and a separate gem per retailer would mean five new
157
+ `require`/`rescue LoadError` seams for zero behavioral gain over the
158
+ `#available?` opt-in gate already in place. Revisit only if a future
159
+ phase adds real purchase automation for one of these.
160
+ `plugins/buy/skills/buy/SKILL.md`, `references/outcomes.md` and
161
+ `references/handoff-only.md` updated; `plugins/buy/.claude-plugin/
162
+ plugin.json` bumped to `0.6.0`. `claude plugin validate .` and `claude
163
+ plugin validate plugins/buy` still pass.
164
+ `portage-cli`: 925 → 961 examples (+36), 0 failures; `rubocop` clean, no
165
+ new cop disables. **Not live-checked at all** (docs/plans/
166
+ buy-skill-and-local-browser.md Phase 7 rule: "you almost certainly have
167
+ no API keys"): every source's spec stubs the HTTP boundary with WebMock,
168
+ none of Walmart/eBay/Best Buy/Etsy/Amazon Creators was called for real,
169
+ and no real cart was touched anywhere.
170
+ - **Portage browser profile** (`docs/plans/buy-skill-and-local-browser.md`
171
+ Phase 6, Tier B). `portage browser profile init|open|status [--browser
172
+ chrome|edge|brave|arc] [--port N] [--url URL (open only)] [--json]`
173
+ manages a dedicated Chromium-family profile directory under
174
+ `~/.portage/browser/<browser>/profile`, launched with remote debugging
175
+ bound to that profile only — never the browser's own default profile
176
+ (`init!` only ever creates its own directory; `Launcher` only ever
177
+ passes that directory as `--user-data-dir`; Chrome 136+ refuses remote
178
+ debugging on the default profile anyway). `open` launches the browser
179
+ if it isn't already running on its own port, waits for its CDP endpoint
180
+ to answer, then attaches to an existing tab or opens a new one at
181
+ `--url`; `status` reports `/json/version`; the user signs into their
182
+ shopping sites in this profile once — nothing here ever reads a
183
+ credential, cookie or autofill store from it.
184
+ `--handoff-target profile` now actually does something: `Cli.
185
+ run_buy`/`execute_buy` attaches a `Portage::Cli::BrowserProfile::Bridge`
186
+ as `Buy#webmcp_bridge` whenever `portage-ucp-webmcp` is installed and
187
+ the profile is running (`Cli.profile_webmcp_bridge`) — reusing an
188
+ existing tab already on the store's host, or opening a new one — so
189
+ `#webmcp_flow` (`docs/plans/webmcp-universal-outbound.md`) drives that
190
+ same browser, and Phase 3's `WebMcp::Autofill` runs in it unchanged
191
+ (`Bridge#headless?` is always `false`, so `autofill_needs_headed_browser`
192
+ never fires for this profile — asserted directly). `Buy#dispatch_to_target`'s
193
+ `"profile"` case now navigates the attached bridge to the checkout URL
194
+ and reports `opened: true`, or — no bridge attached (gem missing,
195
+ profile not running) — tells the shopper to run `portage browser
196
+ profile open` first, same "report, never raise" posture as every other
197
+ hand-off dispatch.
198
+ Driving is limited to a domain allowlist (`Portage::Cli::BrowserProfile::
199
+ Allowlist`, reusing `HandoffOnly`'s own host normalization/matching):
200
+ seeded with the store's own host, and permitted to include a checkout
201
+ host only when `Bridge#navigate` deliberately opens it — a page-driven
202
+ navigation to anything else raises `DomainNotAllowedError` on the very
203
+ next driven call (there's no navigation-event hook in this minimal a
204
+ CDP client, so it's caught on next use, not mid-navigation), which
205
+ `Bridges::ScriptEvaluator#evaluate` re-wraps as its own `BridgeError` —
206
+ still caught by `Buy#webmcp_flow`'s existing rescue, so the run stops
207
+ the same way any other WebMCP failure does. `Portage::Cli::BrowserProfile::
208
+ CdpSocket` is a small, dependency-free WebSocket JSON-RPC client (RFC
209
+ 6455 handshake, masked outbound / unmasked inbound frames) for Chrome
210
+ DevTools Protocol's `Runtime.evaluate`/`Page.navigate`/`Page.enable`;
211
+ `Portage::Cli::BrowserProfile::Cdp` covers the plain-HTTP half
212
+ (`/json/version`, `/json/list`, `/json/new`). No raw card data is ever
213
+ read or typed by any of this; Portage never touches a payment field and
214
+ never clicks pay. `portage-cli`: 865 → 925 examples (+60), 0 failures;
215
+ `rubocop` clean (one documented `Metrics/ClassLength` exclude for
216
+ `CdpSocket`, same "one small self-contained protocol end to end"
217
+ rationale already given to `Index::Builder`/`BrowserImport::Importer`).
218
+ Specs never launch a real browser or open a real socket — `Process.spawn`
219
+ is injected (`Launcher`), CDP HTTP calls go through WebMock
220
+ (`Portage::Ucp::Support::Connection`, same as every other network call
221
+ in this gem), and the WebSocket layer is proven against an in-memory
222
+ fake transport (`cdp_socket_spec.rb`), including RFC 6455's own §1.3
223
+ worked handshake example.
224
+
225
+ - **Hand-off targets + hand-off-only hosts** (`docs/plans/
226
+ buy-skill-and-local-browser.md` Phase 5). `portage buy --handoff-target
227
+ default|print|profile|agent:<name>` (also `PORTAGE_HANDOFF_TARGET` /
228
+ `~/.portage/config.json`'s `"handoff_target"`, same precedence as every
229
+ other `Setting`) decides where a dead-end `checkout_url` goes:
230
+ `default` is today's `CheckoutHandoff` auto-open, unchanged; `print`
231
+ just reports the URL; `profile` is accepted but says the Portage browser
232
+ profile isn't built yet (Phase 6) and behaves like `print`; `agent:<name>`
233
+ passes the checkout URL and the same cart-summary payload
234
+ `--notify-webhook` sends to a named agent the user has approved once in
235
+ `~/.portage/config.json`'s new `handoff_agents` (a `command` argv run
236
+ with a scrubbed environment, stdin JSON and a 30s timeout — never a
237
+ shell string — or an `https` `webhook`), never invoked unless
238
+ `"approved": true`. Every hand-off report's `handoff` object now also
239
+ carries `handoff_target`, plus `agent_delivered`/`agent_error` for an
240
+ `agent:<name>` target. New `Portage::Cli::HandoffTarget`, `HandoffAgents`
241
+ (`lib/portage/cli/handoff_{target,agents}.rb`), and `Buy#handoff_notify_payload`
242
+ now also carries `items:`.
243
+ New `Portage::Cli::HandoffOnly` (`lib/portage/cli/handoff_only.rb`) is
244
+ the one place Tier C's host list lives — every Amazon marketplace by
245
+ default, fully replaced by `~/.portage/config.json`'s
246
+ `"handoff_only_hosts"` once that key is present. `portage buy` against
247
+ one of these hosts returns outcome `handoff_only` **before any request
248
+ to that host** — no UCP probe, no homepage fetch, no cart — with a
249
+ `checkout_url` (a product page/cart-add URL when a product id is known,
250
+ the retailer's own search URL for the query when it's Amazon, or the
251
+ origin's homepage otherwise, always built and never fetched) and a
252
+ `legal_notice` (facts plus the as-is/no-warranty line). `portage find`
253
+ and `portage index build`/`add`/`refresh` never probe a hand-off-only
254
+ origin either — `find` still lists it as a candidate, marked
255
+ `handoff_only: true`. `portage browser import`'s existing
256
+ `handoff_only_hosts:` seam is now wired to the real list. `portage
257
+ compare` and `PaymentMethods#enroll` (`portage payment enroll`) also
258
+ refuse a hand-off-only origin before ever probing it (`compare` reports
259
+ it hand-off only with the legal notice; enrollment reports
260
+ `status: "handoff_only"` — "there's nothing to set up here"), and
261
+ `HandoffReconciler#reconnect` refuses one too, as defense in depth (Buy's
262
+ `handoff_only` outcome never reserves a pending record in the first
263
+ place, so this path shouldn't be reachable, but it's the one place every
264
+ reconcile fetches a store again from a saved record). `HandoffOnly#hosts`
265
+ normalizes each configured entry — `"www.example.com"`,
266
+ `"example.com/path"`, and `"https://www.example.com/s?k=x"` all reduce
267
+ to `"example.com"` — the same normalization `BrowserImport::Importer`
268
+ already applies to a history/bookmark domain. `portage
269
+ doctor` gains a `handoff` finding (current target, the hand-off-only
270
+ list, the disclaimer), and `portage setup`'s Hand-off step now also sets
271
+ the target, approves a named agent, and edits the hand-off-only list.
272
+ **Review caught three more real bugs before this shipped, all fixed:**
273
+ (1) `HandoffAgents::Command`'s timeout never actually killed the child —
274
+ `Open3.popen3`'s block form joins `wait_thr` in its own `ensure` once the
275
+ block returns, so a `Timeout.timeout` firing inside it just meant popen3
276
+ itself then hung waiting for the still-running child; fixed to
277
+ `SIGTERM`, then `SIGKILL` after a short grace, and to actually wait for
278
+ the child to die before leaving the block, plus draining stdout/stderr
279
+ on their own threads (same as `Open3.capture3`) so a child that fills
280
+ the stderr pipe can't deadlock a sequential read of stdout first. (2)
281
+ `Index::Builder#reverify_stale` skipped by an entry's *stored*
282
+ `handoff_only` flag rather than the live config, which was wrong in
283
+ both directions — an origin whose UCP probe simply failed (stored
284
+ `handoff_only: true`, but not a Tier C host) would never be re-verified
285
+ again, and a stale entry recorded before the user added its host to
286
+ `handoff_only_hosts` (stored `handoff_only: false`) would keep being
287
+ probed; fixed to check `handoff_only_origin?` against live config
288
+ instead, flipping a now-hand-off-only entry's stored flag with no
289
+ request when it disagrees. (3) `Compare#resolve_origin` and
290
+ `PaymentMethods#discover_session` still probed whatever origin/store URL
291
+ they were given, including a hand-off-only one (`portage compare
292
+ <amazon-url>` and `portage payment enroll <amazon-url>` both reached
293
+ Amazon) — fixed as described above.
294
+ `portage-cli`: 783 → 865 examples (+82: +70 from the phase's first pass,
295
+ +12 from the review round above — a real-subprocess kill-on-timeout
296
+ spec for `HandoffAgents::Command`, `Index::Builder#refresh` specs for
297
+ both reverify-skip directions plus a `--dry-run` one, and one
298
+ zero-requests spec each for `Compare`, `PaymentMethods#enroll`,
299
+ `HandoffReconciler#reconnect`, and `HandoffOnly#hosts` normalization),
300
+ 0 failures; `rubocop` clean, no new cop disables.
301
+
302
+ - **`portage setup` — interactive setup wizard** (`docs/plans/
303
+ buy-skill-and-local-browser.md` Phase 4). On a TTY, `portage setup` (and
304
+ `portage doctor`/`configure` when `Doctor#nothing_configured?` — no
305
+ `.env` file loaded, no shipping address, no policy at all) now walks
306
+ through seven skippable steps: shipping address, search API keys, the
307
+ agent profile, browser import, the local store index, spending policy
308
+ caps, and hand-off. Under `--json` or with no TTY on stdin, every one of
309
+ `doctor`/`configure`/`setup` stays exactly today's read-only report —
310
+ proven by spec, not just described. New `Portage::Cli::SetupWizard` (`lib/
311
+ portage/cli/setup_wizard.rb` + `setup_wizard/prompt.rb` +
312
+ `setup_wizard/steps/{shipping,search_keys,agent_profile,browser_import,
313
+ index_build,policy,handoff}.rb`). Each step delegates to the real command
314
+ it configures — `portage generate agent-profile`, `portage browser
315
+ import` (with its own confirm-before-save prompt intact), `portage index
316
+ build`, `portage policy set` — rather than reimplementing any of them, so
317
+ the wizard has nothing UCP- or network-specific of its own to get wrong.
318
+ `Portage::Cli::DotEnv` gains `.update!`, the wizard's only writer to
319
+ `~/.portage/.env`: updates a key in place (never duplicating it on a
320
+ re-run), keeps every other line and comment untouched, creates
321
+ `~/.portage` if needed, and is mode 0600 from the very first byte —
322
+ a brand-new file is created with that mode already set (`File.open`,
323
+ not `File.write` then `File.chmod`, which would briefly leave it at the
324
+ process umask's default), and an existing file is chmod'd 600 *before*
325
+ anything is written into it. Every secret prompt (Brave/Google CSE API
326
+ keys) reads via `IO#noecho` on a real terminal and never echoes the
327
+ value back, even in the wizard's own summary; Enter on any question
328
+ keeps whatever's already set rather than clearing it, so re-running the
329
+ wizard is always safe. `Steps::Policy` rejects a non-numeric or
330
+ non-positive spending cap ("abc", "12x", "0") rather than silently
331
+ coercing it to a 0 cap via `String#to_f`, and reuses
332
+ `Portage::Cli.to_minor_units` (private, via `send`) for the major-to-
333
+ minor-units conversion instead of a second implementation. The hand-off
334
+ step configures the one hand-off setting that exists today
335
+ (`CheckoutHandoff`'s auto-open toggle) and explains that a fuller choice
336
+ of hand-off targets and the hand-off-only host list are Phase 5, not
337
+ invented here — a deliberately easy seam for that phase to extend rather
338
+ than a guess at its shape. `portage-cli`: 727 → 783 examples (+56), 0
339
+ failures; `rubocop` clean, no new cop disables.
340
+ - **`portage browser import` — bookmarks and history as index seeds**
341
+ (`docs/plans/buy-skill-and-local-browser.md` Phase 3, Tier A).
342
+ `portage browser import [--browser chrome|edge|brave|arc|firefox|safari]
343
+ [--profile-root DIR] [--history-days 90] [--include-product-pages]
344
+ [--max-probes 200] [--exclude HOST,...] [--dry-run] [--yes] [--json]`
345
+ (new `Portage::Cli::BrowserImport`). Reads only a profile's history and
346
+ bookmark files — Chromium `History`/`Bookmarks`, Firefox
347
+ `places.sqlite`, Safari `History.db`/`Bookmarks.plist`
348
+ (`BrowserImport::Profiles::ALLOWED_FILES`) — and never a password,
349
+ cookie or autofill store; a spec wraps every Ruby file/dir/subprocess
350
+ entry point and fails if anything else in a fixture profile full of
351
+ `Login Data`/`Cookies`/`Web Data`/`logins.json`/`key4.db`/
352
+ `cookies.sqlite`/`formhistory.sqlite` decoys is touched. SQLite
353
+ databases are copied (with their `-wal`) into a private tmpdir and
354
+ queried with the system `sqlite3` CLI in `-readonly -json` mode, then
355
+ deleted; Safari's binary plist goes through macOS's own `plutil` and a
356
+ small XML-plist reader — no new gem dependency. Rows are reduced to
357
+ domains, and obvious non-shops (`SearchBackends::NON_STORE_HOSTS`,
358
+ webmail, banks/payment hosts, localhost/intranet/IP hosts, common
359
+ tools and social sites) are skipped locally, as are domains already in
360
+ the local index or the known-stores list. At most `--max-probes`
361
+ (default 200) unknown domains, most-visited first, each get one
362
+ `GET /.well-known/ucp` through `Portage::Ucp::Client.discover` and
363
+ `ProbeCache` (5s timeout) — nothing else about the history is sent
364
+ anywhere. A domain is kept when it answers, matches a WebMCP preset
365
+ (skipped with no bridge), or is on the hand-off-only list (an
366
+ injectable `handoff_only_hosts:` seam, empty until Phase 5). Each kept
367
+ domain is classified from the user's own page titles, bookmark folder
368
+ names and URL slugs, weighted by visit count; a domain that matches no
369
+ category stays uncategorised. The list (with category names) is shown
370
+ first and saved only after a "y" at a TTY prompt or an explicit
371
+ `--yes` — under `--json` or with no TTY and no `--yes`, nothing is
372
+ written and the report says `needs_confirmation: true`. Safari without
373
+ Full Disk Access (and any other permission error) is explained and the
374
+ command stops; it never works around it. Kept domains are written to
375
+ `~/.portage/index/stores.json` with `sources: ["history"]`/
376
+ `["bookmark"]`; `--include-product-pages` also writes product-page
377
+ titles to `products.json` with the same labels.
378
+ - **`SearchBackends::Index` routes a store the query names** (its host, or
379
+ its bare name as a whole word), after product matches and ahead of
380
+ category matches — the only route an uncategorised browser-imported
381
+ domain gets. Imported entries are otherwise ordinary untrusted index
382
+ entries: `source: "index"`, never `merchant_allowlist`, never past the
383
+ `--store`/interactive-pick gate for `--yes`.
384
+ - **`Index::Exporter` treats `history`/`bookmark` as personal** (alongside
385
+ `browser`): a store found only that way is never exported, the labels
386
+ are stripped from mixed-source entries, and a product whose only
387
+ `sources` are personal (a `--include-product-pages` entry) is dropped
388
+ even at a store a real source also found. `Index::ProductStore` entries
389
+ now carry a `sources` list (the union of every sighting's), and
390
+ `index build` records its own source on each product.
391
+ - **`Index::Sources::Browser` is now a pointer, not a reader.** It yields
392
+ nothing, isn't a default `index build` source any more, and its
393
+ `portage index sources` description points at `portage browser import`
394
+ — `index build` never reads a browser on its own, since that would skip
395
+ the import's approval step. The Phase 2b `browser-import.json` hand-off
396
+ file is gone.
397
+ - `Classifier.names_for(ids)` (category names for display) and
398
+ `Index::Builder.capabilities_of(session)` are now public, for the
399
+ import to reuse.
400
+
401
+ - **`portage-cli/known-stores/{stores,products}.json` — a repo-committed,
402
+ jsdelivr-hosted list every install fetches on top of its own local index**
403
+ (`docs/plans/buy-skill-and-local-browser.md` Phase 2c). Same schema as
404
+ `~/.portage/index/{stores,products}.json` (Phase 2b), published over the
405
+ same `@main` jsdelivr channel `AgentProfileUrl` already uses
406
+ (`Portage::Cli::KnownStoresUrl`), and cached at
407
+ `~/.portage/index/known-{stores,products}.json`
408
+ (`Portage::Cli::Index::KnownCache`). Fetched lazily the first time
409
+ `SearchBackends::Index` needs it and no cache exists yet, refreshed
410
+ unconditionally by `portage index refresh`, and refreshed by `doctor`
411
+ whenever it finds the cache older than 7 days — every fetch is a single
412
+ short-timeout GET per file, swallowed on any failure (offline, timeout,
413
+ bad JSON, a non-2xx response) exactly like `SearchBackends`/
414
+ `OfferSources::ShopifyCatalog`, so a missing or stale cache just means
415
+ `find` works the way it did before this phase. A fetched entry is
416
+ validated (must parse as a Hash of Hashes) and stripped of any
417
+ `price`/`amount`/`stock` field before it's cached. `SearchBackends::Index`
418
+ now merges this cache *underneath* the user's own
419
+ `Index::Store`/`Index::ProductStore` entries — a known entry only shows
420
+ up when the user's own index doesn't already have that origin/key, so a
421
+ local `index add`/`index build` finding always wins — and is now
422
+ `available?` from the known cache alone, with no local index at all.
423
+ Still the same untrusted posture either way: an offer built from either
424
+ source carries `source: "index"`, never a `merchant_allowlist`/`--yes`
425
+ shortcut.
426
+ - **`portage index build`/`refresh --export DIR`** writes a PR-ready copy
427
+ of your own local index into `DIR/{stores,products}.json` — the same
428
+ shape as `known-stores/` in the repo, so publishing a new entry is
429
+ "run this, `git add`, open a PR." A new `Index::Exporter` drops any store
430
+ entry whose only `sources` is `"browser"` (history/bookmark-derived —
431
+ Phase 3) outright, strips `"browser"` out of the `sources` of any entry
432
+ that also has a real source, and keeps a product only if at least one of
433
+ its `stores[].origin` survived that same filter (trimming its `stores`
434
+ array down to just those origins) — nothing personal ships in an export.
435
+ - **`rake agent_profile:purge` is now an alias for `rake
436
+ jsdelivr:purge[agent_profile]`.** The new `jsdelivr:purge` task (root
437
+ `Rakefile`) covers every file this repo publishes over jsdelivr's
438
+ `@main` channel by short name — `agent_profile`, `known_stores`,
439
+ `known_products` — and purges all of them when called with no argument.
440
+ - Seeded `known-stores/{stores,products}.json` with a real
441
+ `portage index build --sources shopify_catalog --export known-stores`
442
+ run: 221 stores, 396 products, all `source: "shopify_catalog"`.
443
+ - **`portage index` — a local store and product index you build yourself**
444
+ (`docs/plans/buy-skill-and-local-browser.md` Phase 2b). New commands:
445
+ `portage index build [--sources a,b] [--queries FILE] [--dry-run]`,
446
+ `index refresh` (re-verifies entries older than 7 days, then builds
447
+ fresh), `index show [--stores|--products] [--json]`, `index add URL`,
448
+ `index remove HOST`, `index sources`. Storage is
449
+ `~/.portage/index/stores.json` and `products.json` — **never checked
450
+ into git, never prices or stock** (those stay live), and `doctor` reports
451
+ whether each file exists and how old its oldest verified entry is. One
452
+ small source file each under `lib/portage/cli/index/sources/`:
453
+ `shopify_catalog` (reuses `OfferSources::ShopifyCatalog` rather than
454
+ duplicating its catalog-search/variant-URL logic; one query per
455
+ top-level taxonomy node by default — 21 nodes, its own keyword rather
456
+ than its full taxon name — or `--queries FILE`; not fanned out
457
+ per-country, since `BuyerContext.from_env` already applies whatever
458
+ locale the user has set), `stores_file` (the user's own `stores.yml`,
459
+ via a new public `Allowlist#stores` reader), `browser` (reads Phase 3's
460
+ eventual output file if present, yields nothing otherwise — Phase 3
461
+ doesn't exist yet), `wikidata` (SPARQL for retail chains' official
462
+ sites, CC0 — off by default, opt in with `--sources wikidata`: a live,
463
+ read-only trial run returned real hits, but most skew toward chains with
464
+ no UCP/WebMCP support at all), `webmcp_sweep` (needs a browser bridge
465
+ that doesn't exist yet — skips cleanly). Every new origin gets one
466
+ `/.well-known/ucp` probe through the existing `ProbeCache`, throttled,
467
+ with progress output, capped at 500 per run. A new
468
+ `SearchBackends::Index` applies Phase 2a's per-category/total routing
469
+ caps to index entries, ranking a category's own matches by its weight
470
+ (highest first), and additionally matches a query against a product by
471
+ name or GTIN — whole-word, on the same `Classifier.tokenize`/
472
+ `.word_match?` the category router itself uses (now public), never a
473
+ substring — putting that product's own stores first; ranked between
474
+ `Allowlist` and the web-search backends in `SearchBackends.default`. The
475
+ index is untrusted data: it never writes
476
+ `Portage::Ucp::Policy#merchant_allowlist`, and an index-sourced offer
477
+ never lets `--yes` alone complete a buy — the same interactive-pick gate
478
+ a web-search offer already gets.
479
+ - **`Classifier` and `known-stores/categories.yml`: a shared category
480
+ taxonomy for `find` routing** (`docs/plans/buy-skill-and-local-browser.md`
481
+ Phase 2a). `known-stores/categories.yml` ships in the gem — the top two
482
+ levels of Google's published product taxonomy (213 nodes, keyed by
483
+ Google's own numeric ids, each with a handful of plain keywords, the
484
+ node's own taxonomy words) — and `~/.portage/categories.yml` overrides a
485
+ shipped node (same id) or extends the taxonomy (a new id).
486
+ `Portage::Cli::Classifier.categories_for(text)` takes a query, a product
487
+ title/description, or a store/product URL (extracting the slug from
488
+ `/products/`, `/collections/`, `/c/` or `/category/` and splitting
489
+ `-`/`_` into words) and returns category ids ranked by keyword hits — no
490
+ LLM, no network, so it works offline on a fresh gem/brew install. One
491
+ classifier for queries, catalog products, stores.yml and (later) browser
492
+ imports. Matching is whole-word, plus simple plural normalization
493
+ (`Classifier.word_match?`: an exact match, a trailing `s`/`es`, or the
494
+ `y`/`ies` swap — "boot"/"boots", "battery"/"batteries") — deliberately
495
+ not a substring check, since a substring match goes both ways regardless
496
+ of word boundaries ("carpet" contains "pet", "chair" contains "hair",
497
+ "scarf" contains "car", "hot sauce" would have hit "photography") and an
498
+ early classifier-spec run caught exactly that class of false positive
499
+ before this shipped.
500
+ - **`stores.yml` entries may now carry `categories:`, and `find` routes by
501
+ them instead of crowding out other candidates.** The file stays a bare
502
+ URL list by default; an entry becomes `{url:, categories: [...]}` only
503
+ once you tag it, and `PORTAGE_STORES` is unaffected either way.
504
+ `SearchBackends::Allowlist#search` now classifies the query, puts any
505
+ entry the query names outright first (its host or bare name appears in
506
+ the query text, tagged or not), then adds up to 3 tagged stores per
507
+ matching category not already named, capped at 12 in total
508
+ (`Allowlist::PER_CATEGORY_CAP`/`TOTAL_CAP`, mirroring `Find::MAX_PROBES`).
509
+ When no tagged store matches any of the query's categories at all, it
510
+ falls back to named entries plus every *untagged* entry — never a
511
+ tagged-but-unrelated one — so a `stores.yml` with no tags at all behaves
512
+ exactly as it did before this change. Once at least one tagged store
513
+ matches, though, nothing falls back to "every store": that's the
514
+ crowding a heavily-populated `stores.yml` used to cause on every single
515
+ query. Trust is unaffected — every allowlist entry is still always a
516
+ candidate to `find`; this only changes which of them spend this query's
517
+ probe slots, and in which order.
518
+ - **`find` gains a second backend kind: `OfferSource`, and a first
519
+ implementation, `OfferSources::ShopifyCatalog`**
520
+ (`docs/plans/buy-skill-and-local-browser.md` Phase 1). Unlike the
521
+ URL-returning `SearchBackends`, an `OfferSource#offers(query, limit:,
522
+ context:)` answers offers directly — no `/.well-known/ucp` probe, and it
523
+ counts toward nothing in `Find::MAX_PROBES`. `Find#call` merges its
524
+ offers with the probed ones before `Decisions.rank`. `ShopifyCatalog`
525
+ calls Shopify's global catalog (`catalog.shopify.com/api/ucp/mcp`)
526
+ anonymously and turns every result into an offer on the *merchant's* own
527
+ origin: a catalog product's own id is a global one no merchant
528
+ recognises, but each `variants[].id` is the merchant's real
529
+ `ProductVariant` gid and `variants[].url` sits on the merchant's own
530
+ domain, so the offer's `store`/`product_id`/`url` come from the first
531
+ variant, not the product (live-verified 2026-09-28, query "hiking
532
+ boots", GB/GBP — the four distinct merchant origins in the top 10 all
533
+ answered `/.well-known/ucp`). `Buy#select_product` now also matches a
534
+ product by one of its variant ids, and `#line_item_id_of` checks that
535
+ exact variant out, so a `find`-picked catalog offer buys correctly with
536
+ no title re-search. 5s timeout; any failure (network, timeout, a
537
+ malformed result) is swallowed the same way a broken `SearchBackends`
538
+ backend already is.
539
+ - **`PORTAGE_AGENT_PROFILE` now defaults to the repo's own published
540
+ profile** (`Portage::Cli::AgentProfileUrl::DEFAULT`, the same jsdelivr
541
+ URL `docs/agent-profile.md` already documents) when the env var is unset
542
+ or blank, instead of `find`/`buy` failing outright on a fresh install
543
+ that never copied `.env.example`. `portage doctor` reports which profile
544
+ is in use — the env var's value, or that it's falling back to the
545
+ default.
546
+ - **Fixed: `dry_run: true` was ignored on the WebMCP hand-off path**
547
+ (docs/design-log.md §51). `Buy#webmcp_handoff_checkout_flow` still added
548
+ to the store's real cart, sent the bridge's tab to checkout and ran
549
+ autofill. It now stops after the read-only search and returns a
550
+ `dry_run` report with a `would:` key (line item, hand-off tool, whether
551
+ autofill would run). No cart, no hand-off, no prompt, nothing typed.
552
+ - **Docs for Phases 1-3** (docs/plans/webmcp-universal-outbound.md Phase 4,
553
+ no code change): the README's "WebMCP (library use, opt-in)" section now
554
+ covers the schema-matched/confirmed fallback for a page no preset
555
+ recognizes, and `--autofill`/`PORTAGE_WEBMCP_AUTOFILL=approve`/
556
+ config.json's `webmcp_autofill` — what it will and won't touch, and where
557
+ it stops (`autofill_needs_headed_browser`, `autofill_blocked`).
558
+ - **Approved autofill of the store's checkout**
559
+ (docs/plans/webmcp-universal-outbound.md Phase 3). Opt-in, per run:
560
+ `--autofill`, or `PORTAGE_WEBMCP_AUTOFILL=approve` / config.json's
561
+ `"webmcp_autofill": "approve"` (`Portage::Cli::WebmcpAutofillMode`) — off
562
+ by default, and a generic truthy value like `"true"` doesn't turn it on,
563
+ only the literal `"approve"`. Even opted in, nothing is typed until the
564
+ shopper approves the exact field/value pairs in a new prompt
565
+ (`Portage::Cli::WebmcpAutofillConfirm`) — refused outright with no prompt
566
+ at all under `--json` or with no TTY, same posture as Phase 2's
567
+ `WebmcpMappingConfirm`. Fields are contact email
568
+ (`PORTAGE_SHIP_EMAIL`, new) and the shipping address `Portage::Cli::
569
+ ShippingProfile` already reads from `PORTAGE_SHIP_*`, mapped onto WHATWG
570
+ autocomplete tokens by the new `Portage::Cli::WebmcpAutofillFields`.
571
+ `Buy#webmcp_handoff_checkout_flow` runs this once the bridge's browser has
572
+ navigated to the store's own checkout (right after the hand-off tool
573
+ call), via `portage-ucp-webmcp`'s new `WebMcp::Autofill` — never a payment
574
+ field, never the pay button, and the run still always ends in
575
+ `express_stop`, unchanged. A headless bridge (or one that never says)
576
+ reports `autofill_needs_headed_browser`; a CAPTCHA/challenge on the page
577
+ reports `autofill_blocked`; either way nothing is touched. The report
578
+ gets a new `autofill:` key only when an attempt was actually made — a run
579
+ with the mode off, or nothing configured to fill, looks exactly like it
580
+ did before Phase 3 existed. `Buy.new` gained `autofill:` (the
581
+ `--autofill` flag's value) and `webmcp_autofill_confirm:` (injectable, for
582
+ specs). Live checks (does autofill actually reach a real Shopify checkout
583
+ page's fields; is the cheapest-rate heuristic right against a real rate
584
+ picker) are pending — no browser or live storefront available this
585
+ session; noted in the plan's Progress log.
586
+ - Fix: `Buy#webmcp_flow` never ran against a real page. The Session
587
+ `WebMcp.connect` returned had `nil` capabilities, so the cart/checkout
588
+ check always fell through to adapter detection. Fixed in
589
+ `portage-ucp-webmcp` (capabilities now come from the page's tools); the
590
+ new `buy_spec` case goes through the real `connect`.
591
+ - `Buy#webmcp_flow` (docs/plans/webmcp-universal-outbound.md Phase 1) now
592
+ detects a known WebMCP platform from the page's own tools
593
+ (`portage-ucp-webmcp`'s new `Presets`) and, for a page whose tools are
594
+ hand-off-only for checkout (Shopify's `proceed_to_checkout` is the first
595
+ such preset — it navigates the browser rather than returning data),
596
+ builds a cart, calls the hand-off tool, and reads the checkout URL off
597
+ its result or, failing that, the tab's own `location.href` — then hands
598
+ off exactly like any other WebMCP checkout.
599
+ - **Schema matching for a page `Presets.detect` doesn't recognize**
600
+ (docs/plans/webmcp-universal-outbound.md Phase 2). `Buy#webmcp_flow`
601
+ falls back to `portage-ucp-webmcp`'s new `Matcher.propose` when a plain
602
+ connect (no preset, no `tool_names:`) doesn't advertise cart/checkout — a
603
+ page that already speaks a preset's names or the bare UCP action names
604
+ never reaches it at all. Read actions (`search_catalog`, `get_product`,
605
+ `get_cart`) are used straight off the proposal; a mutating action
606
+ (`create_cart`, `update_cart`, `create_checkout`) needs confirmation
607
+ first: `Portage::Cli::WebmcpMappingConfirm` prints the proposed mapping —
608
+ quoting each mutating tool's own `description` as untrusted page content,
609
+ never as an instruction — and prompts on a real TTY with `--json` off;
610
+ otherwise the run stops with outcome `webmcp_mapping_unconfirmed` and
611
+ hands back the full proposal (`tool_names_proposal`) for the caller to
612
+ resubmit as `tool_names:` on its own `WebMcp.connect` call. A confirmed
613
+ mapping is stored in `~/.portage/webmcp_mappings.json`
614
+ (`Portage::Cli::WebmcpMappings`), keyed by the page's tool fingerprint
615
+ (`WebMcp::Fingerprint.for` — sorted tool names plus a hash of each input
616
+ schema) rather than by origin, so it's shared across every store whose
617
+ WebMCP tools have the exact same shape (decision 3) — in effect a local
618
+ preset once confirmed once. `Buy.new` takes three new optional keywords:
619
+ `webmcp_mappings:`, `webmcp_mapping_confirm:` (both injectable for specs)
620
+ and `json:` (now threaded from `portage buy --json`, which no longer
621
+ strips it before building `Buy` — Phase 2's confirm gate needs to know).
622
+
7
623
  ## [0.7.5] - 2026-09-25
8
624
 
9
625
  - **`portage` loads `~/.portage/.env` on startup** (`Portage::Cli::DotEnv`),