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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +559 -0
- data/README.md +266 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +155 -1
- data/lib/portage/cli/dot_env.rb +55 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +525 -5
- metadata +58 -2
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 805f3c78c6560bcdb4110d1bb97dcaa0700a7586578cc639fb94595cfd5c09bc
|
|
4
|
+
data.tar.gz: f2f4ca834b30fb319563ea10741385150559437bdfd87bc4657051d1f2dc0182
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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`),
|