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