portage-cli 0.7.0 → 0.7.3

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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 5c2f71cd7ac7290b314bc824678fc8a9fbc31b6e43cdea4c4e78077440c555c9
4
- data.tar.gz: f63510ff1b5d5d945e6e9753b0cf1a521d8da6b084d562c230c4d6b5a98ad6ad
3
+ metadata.gz: 996ddc0acd1ec2676257f402fbb03148e8a38c40af9d5cf4fbe50d176416a334
4
+ data.tar.gz: a451d4e8130b1891fb2630d9b5e9edc1efde9486afda2ca3ff863342943d4a09
5
5
  SHA512:
6
- metadata.gz: 802fb90a17c93986ff7f9101e09e845af467e11607985ddd62cefcd4704f25c5f05292f871c168adeebf386d92620471d6ea6bd8273a74fabb11a3c765324f7d
7
- data.tar.gz: '021008e8975eb3002986f0e9f27d5d776b1da067c3fbc6083909abbecde99a616680d3569c609fae0d87ae402e4002577c8cca6bd62ca4811d95c580d8ba8d10'
6
+ metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
7
+ data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
data/CHANGELOG.md CHANGED
@@ -4,6 +4,113 @@ 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
+ ## [0.7.3] - 2026-09-25
8
+
9
+ - **Proxy support** (`docs/plans/proxy-support.md` Phases 2-3). Every
10
+ network command (`buy`, `find`, `compare`, `doctor`, `payment enroll`)
11
+ takes `--proxy`, `--proxy-mode`, `--proxy-header`, `--no-proxy`,
12
+ `--proxy-route`, `--proxy-chain`, `--proxy-passthrough`, `--proxy-ca` and
13
+ `--no-env-proxy`, resolved per field against `PORTAGE_PROXY*` env vars
14
+ and `~/.portage/config.json`'s `proxy` section into core's
15
+ `Support::ProxyConfig`. `proxy.password_ref` resolves through Keychain or
16
+ Secret Service (service `portage-cli-proxy`). The payment route stays
17
+ direct unless a proxy is named for it explicitly, so payment traffic no
18
+ longer inherits a bare `$http_proxy`/`$https_proxy`. `portage doctor`
19
+ probes each configured proxy through `Support::Connection` and warns on
20
+ plaintext credentials in `config.json`.
21
+ - Requires `portage-ucp` `~> 0.10` (for `Support::Connection` and
22
+ `ProxyConfig`) and `portage-ucp-client` `>= 0.6.3` (for
23
+ `Client::USER_AGENT`, which the default User-Agent is built from). Both
24
+ floors were too low before: against `portage-ucp` 0.9.0 or
25
+ `portage-ucp-client` 0.6.2, `require "portage/cli"` raised `NameError`.
26
+ - `portage --version` (also `-v` and `portage version`) prints the gem's
27
+ version and exits 0. Needed so a packaged install — the Homebrew
28
+ formula's offline `test do` block, or anyone else scripting around a
29
+ release — can confirm which build is on `PATH` without hitting the
30
+ network.
31
+ - **`portage-console` failed to start on Ruby 4.0.** It requires `irb`,
32
+ which stopped being a default gem in Ruby 4.0 (it's a bundled gem now),
33
+ and the gemspec never declared it, so any isolated install
34
+ (Homebrew's, or anything under Bundler) raised `LoadError: cannot load
35
+ such file -- irb`. `irb` is now a runtime dependency.
36
+ - **Did the shopper finish the checkout? `portage orders reconcile`.**
37
+ Every checkout `portage buy` hands to the shopper's own browser
38
+ (`requires_escalation`, `permission_denied`, `no_payment_token`,
39
+ `policy_blocked`, `low_confidence`, `checkout_mismatch`) now reserves a
40
+ pending record in the transaction log (best-effort — a failed write is a
41
+ report warning, never blocks the hand-off). `portage orders reconcile
42
+ [--checkout ID] [--json]` re-fetches each pending checkout from the store
43
+ and settles it: `complete` only on a store-reported `completed` status,
44
+ `failed` on `canceled` or an unanswered expiry, otherwise stays pending.
45
+ Never infers success from a vanished checkout or a plain timeout. Safe to
46
+ run from cron/launchd. See `docs/plans/handoff-reconcile.md`.
47
+ - **`handoff_spend_mode`** (`PORTAGE_HANDOFF_SPEND_MODE`, config.json) —
48
+ whether a reconciled shopper purchase counts toward the buyer's own spend
49
+ cap/velocity limit. `block` (default): counts like any agent purchase.
50
+ `warn`: recorded but excluded from cap math. `precheck`: `block`, plus a
51
+ spend-cap check at hand-off time that suppresses auto-open (never the
52
+ URL) when this checkout would already exceed the cap.
53
+ - Phase 0 of the plan above (a live signal check against a real store) was
54
+ not run before this shipped — see `docs/design-log.md` §44.
55
+ - **`portage buy --wait [--wait-timeout DURATION|off]`.** After a hand-off,
56
+ polls `HandoffReconciler` with backoff (2s → 30s, plus jitter) until the
57
+ checkout settles or its deadline passes — the earlier of
58
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json;
59
+ default 30m, `off` removes it) and the checkout's own `expires_at`.
60
+ Ctrl-C or the deadline leaves the record pending for a later `portage
61
+ orders reconcile`; it never settles from the wait itself. Under `--wait
62
+ --json`, stdout streams NDJSON (`handoff`, `handoff_status` on each
63
+ store-reported status change, `handoff_settled`) followed by the final
64
+ report object; plain `--json` with no `--wait` is unchanged byte-for-byte.
65
+ - **`reconcile_notify`** (`PORTAGE_RECONCILE_NOTIFY`, config.json) — a comma
66
+ list of channels a settled hand-off notifies on, from `--wait` or `portage
67
+ orders reconcile`. Default `webhook`. Adds `macos` (a native notification,
68
+ merchant/amount escaped into a fixed AppleScript template) and `terminal`
69
+ (a printed line, forced on for a plain-text `--wait` regardless of
70
+ configuration). `journal` is accepted for documentation — the Phase 1
71
+ order-snapshot journal write was already unconditional.
72
+ - **WebMCP outbound, opt-in (Phase 4, partial).** `Buy.new(webmcp_bridge:)`
73
+ takes a `portage-ucp-webmcp` outbound bridge already pointed at a
74
+ navigated page; when given one, `portage-cli` tries it after native-UCP
75
+ discovery finds nothing and before a platform-adapter fallback. Only
76
+ `webmcp_checkout_mode: express_stop` (the default) is implemented: it
77
+ builds the cart/checkout and always hands off (reason `express_stop`),
78
+ feeding Phases 1-3 unchanged, since a WebMCP page doesn't expose
79
+ `complete_checkout` by default anyway. `token` mode reports
80
+ `webmcp_token_unsupported` rather than attempting completion — it still
81
+ needs a `Confirmer`-swap seam in `Mcp::Server.build` and payment-token
82
+ enrollment that don't exist yet. No new hard runtime dependency:
83
+ `portage-ucp-webmcp` is lazily `require`d, same posture as an optional
84
+ platform adapter gem.
85
+
86
+ ## [0.7.2] - 2026-09-24
87
+
88
+ - The User-Agent every store-facing request sends (`Cli::UserAgent`, née
89
+ `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`) is now configurable: `PORTAGE_USER_AGENT`
90
+ beats `~/.portage/config.json`'s `user_agent` key, both of which beat the
91
+ `portage-cli/<ver> portage-ucp-client/<ver> (+https://github.com/...)`
92
+ default — same precedence `Notifier`'s webhook URL already uses. `portage
93
+ doctor` (and its `configure`/`setup` aliases) now flags a configured value
94
+ containing a stray newline, since `Net::HTTP` would otherwise raise on it
95
+ mid-checkout instead of at setup time.
96
+
97
+ ## [0.7.1] - 2026-09-24
98
+
99
+ - Fix: `buy <url> --max-price` (and `--product-id` with `--max-price`) was
100
+ silently ignored — `add_search_options` only wired `--max-price` into the
101
+ `find` options, not `buy`, so a URL-driven buy could complete over the cap
102
+ the caller set. `Buy#select_product` now filters to products within
103
+ `max_price` first, using a variant's own price where available and
104
+ otherwise the product's lowest price (unpriced products stay eligible,
105
+ same rule `Find` already used). `no_match_message` now names the cap.
106
+ - Every store-facing request (`discover()` calls and the notifier webhook
107
+ POST) now sends `portage-cli/<ver> portage-ucp-client/<ver>
108
+ (+https://github.com/tomtom87/Portage)` instead of Ruby's or Faraday's
109
+ default User-Agent. New `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`
110
+ (`lib/portage/cli/user_agent.rb`) replace the ad-hoc UA strings each of
111
+ `portage-buy`, `portage-find` and `portage-payment-enroll` built on their
112
+ own. Requires `portage-ucp-client >= 0.6.3`.
113
+
7
114
  ## [0.7.0] - 2026-09-23
8
115
 
9
116
  - **The decision layer is wired in.** `buy`/`find` make their judgment
data/README.md CHANGED
@@ -70,6 +70,7 @@ gem install portage-cli
70
70
  portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
71
71
  [--yes] [--dry-run] [--decision-backend jev|laya]
72
72
  [--min-confidence N] [--json]
73
+ [--wait [--wait-timeout DURATION|off]]
73
74
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
74
75
  portage find --query "..." [--max-price N] [--limit N] [--json]
75
76
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -88,8 +89,20 @@ portage policy set [--per-transaction-cap N --currency CUR]
88
89
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
89
90
  [--velocity-count N --velocity-window-seconds N]
90
91
  [--allow HOST ...] [--clear-allowlist]
92
+ portage orders reconcile [--checkout ID] [--json]
91
93
  ```
92
94
 
95
+ `buy`/`find`/`compare`/`doctor`/`payment enroll` (the network-touching commands —
96
+ `orders reconcile` doesn't take these) also accept:
97
+
98
+ ```
99
+ [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
100
+ [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
101
+ [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
102
+ ```
103
+
104
+ See "Proxy" below.
105
+
93
106
  - `--query` — search term. Against the store's catalog when you name a store,
94
107
  against the search backends when you don't.
95
108
  - `--qty` — quantity, default `1`.
@@ -102,7 +115,10 @@ portage policy set [--per-transaction-cap N --currency CUR]
102
115
  search ranks first. If the id isn't in the results, nothing is bought.
103
116
  - `--store` — name the merchant without giving a full URL; skips the search.
104
117
  - `--max-price` — in major units (`400` means 400), compared per offer in that
105
- offer's own currency. No FX conversion.
118
+ offer's own currency. No FX conversion. Applies per unit, to the search and
119
+ to the store's own catalog once one is settled (a URL, `--store`, or a
120
+ picked offer): nothing priced above it is checked out, even with
121
+ `--product-id`. A product with no price is still eligible.
106
122
  - `--limit` — how many candidate stores to probe, capped at 12.
107
123
  - `--yes` — skip the confirmation prompt before completing checkout.
108
124
  - `--dry-run` — resolve and price the order without completing checkout.
@@ -270,6 +286,135 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
270
286
  limits bound to one enrolled card) are set via `portage payment enroll
271
287
  --scope-*` above, not here.
272
288
 
289
+ ### Orders reconcile
290
+
291
+ Nearly every real checkout `portage buy` can't finish itself hands the
292
+ shopper a link — `requires_escalation`, `permission_denied`,
293
+ `no_payment_token`, `policy_blocked`, `low_confidence`, or
294
+ `checkout_mismatch`. That's a pending purchase this process knows nothing
295
+ more about until something asks the store. `portage orders reconcile`
296
+ re-fetches each pending hand-off from the store and settles it once the
297
+ store itself reports a terminal status:
298
+
299
+ ```bash
300
+ portage orders reconcile # every pending hand-off
301
+ portage orders reconcile --checkout chk_123
302
+ portage orders reconcile --json
303
+ ```
304
+
305
+ A `completed` checkout settles `complete` — using the store's own total at
306
+ settle time, not the hand-off-time snapshot — records the order (when one
307
+ exists) and a journal entry, and is counted toward your spend policy's
308
+ rolling cap/velocity per `handoff_spend_mode` below. A `canceled` checkout,
309
+ or one that expires with no answer, settles `failed`. Anything still
310
+ in-progress, or not currently reachable, is left pending for the next run —
311
+ safe to put on a cron/launchd schedule; two overlapping runs never
312
+ double-settle the same record.
313
+
314
+ `handoff_spend_mode` (`PORTAGE_HANDOFF_SPEND_MODE`, config.json's
315
+ `handoff_spend_mode`) controls whether a reconciled shopper purchase counts
316
+ toward the caps `portage policy set` configures:
317
+
318
+ - `block` (default) — counts like any agent-completed purchase.
319
+ - `warn` — recorded, but excluded from the cap/velocity math; still notifies
320
+ when it would have pushed spend over the cap.
321
+ - `precheck` — `block`, plus a spend-cap check at hand-off time
322
+ (`portage buy`, not reconcile): if this checkout's total would already
323
+ exceed your cap, the URL is still printed but auto-open is suppressed.
324
+
325
+ `--wait [--wait-timeout DURATION|off]` on `portage buy` polls the same
326
+ reconciler right after a hand-off, instead of waiting for a separate
327
+ `orders reconcile` run. It backs off (2s → 30s, plus jitter) until the
328
+ checkout settles or its deadline passes — the earlier of
329
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json; default
330
+ `30m`, `off` removes it) and the checkout's own `expires_at`. Ctrl-C, or
331
+ the deadline, leaves the record pending for a later `portage orders
332
+ reconcile` — it never settles from the wait itself. Under `--wait --json`,
333
+ stdout streams NDJSON — `handoff`, `handoff_status` on each store-reported
334
+ status change, `handoff_settled` — followed by the final report object;
335
+ plain `--json` without `--wait` is unchanged.
336
+
337
+ `reconcile_notify` (`PORTAGE_RECONCILE_NOTIFY`, config.json's
338
+ `reconcile_notify`) is a comma list of channels a settled hand-off notifies
339
+ on, from `--wait` or `orders reconcile`: `webhook` (default), `macos` (a
340
+ native notification), `terminal` (a printed line — forced on for a
341
+ plain-text `--wait` regardless of configuration), and `journal` (the order
342
+ snapshot journal write, already unconditional — listing it just documents
343
+ that).
344
+
345
+ ### Proxy
346
+
347
+ `buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
348
+ resolved (per field, flag > env > `~/.portage/config.json`) into a
349
+ `Portage::Ucp::Support::ProxyConfig` by `Portage::Cli::ProxySettings` — see
350
+ [`docs/proxy.md`](../docs/proxy.md) for corporate egress, a rotating residential
351
+ pool, an API gateway, mitmproxy for debugging, and nginx/Cloudflare in front of the
352
+ MCP/WebMCP endpoints, worked through end to end.
353
+
354
+ | Flag | Meaning |
355
+ | --- | --- |
356
+ | `--proxy URL` | The default proxy's URL (`http://user:pass@host:port`). Overrides only `proxy.default.url`; every other configured field (`no_proxy`, `routes`, `chains`, ...) stays as set in config.json. |
357
+ | `--proxy-mode forward\|gateway` | The default profile's mode. `forward` (the default) is a standard HTTP proxy; `gateway` is a URL-rewriting gateway — see `docs/proxy.md`. |
358
+ | `--proxy-header "Name: value"` | Repeatable. Sent only to the proxy (on the `CONNECT` request, or the gateway request) — never to the real target. `Authorization`/`User-Agent`/`X-Shopify-*-Access-Token`/`X-Payment-Token` are refused here, always. |
359
+ | `--no-proxy HOSTS` | Comma-separated hostnames/suffixes/CIDRs (or a bare `*`) to always bypass the proxy for, whatever the route resolves to. |
360
+ | `--proxy-route ROUTE=URL\|direct` | Repeatable. Points one fixed route (`store`, `search`, `notify`, `payment`, `platform`, `probe`) at its own URL, or forces it `direct` regardless of `default`. |
361
+ | `--proxy-chain URL,URL,...` | An ad-hoc multi-hop chain for this run — each hop is `forward` unless prefixed `gateway+https://...`. Overrides the whole `default` profile (not just its `url`), since a chain has no single "url" field to merge with config.json's own. |
362
+ | `--proxy-passthrough HEADER` | Repeatable, server commands only. Allowlists an inbound request header to ride along on outbound calls made while serving it — see `docs/proxy.md`'s passthrough section. Refuses a protected header name at parse time. |
363
+ | `--proxy-ca FILE` | A PEM file trusted in addition to the system store — for a TLS-intercepting corporate proxy or a debugging proxy like mitmproxy. |
364
+ | `--no-env-proxy` | Ignore `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and their lowercase forms) entirely for this run — otherwise they're still the fallback for any route nothing else configured. |
365
+
366
+ **Env vars** (checked when the matching flag is absent, before config.json):
367
+
368
+ | Var | Matches |
369
+ | --- | --- |
370
+ | `PORTAGE_PROXY` | `--proxy` |
371
+ | `PORTAGE_PROXY_MODE` | `--proxy-mode` |
372
+ | `PORTAGE_NO_PROXY` | `--no-proxy` |
373
+ | `PORTAGE_PROXY_CA` | `--proxy-ca` |
374
+ | `PORTAGE_PROXY_HEADERS` | A JSON object of header name → value, merged under any `--proxy-header` flags (flags win on a name collision). |
375
+
376
+ Below all of the above, the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and
377
+ lowercase) variables are still the fallback for any route nothing here configures
378
+ at all — existing environments keep working unless `--no-env-proxy` is set. The
379
+ **`payment` route is always forced `direct`** unless a proxy is named for it
380
+ explicitly (`--proxy-route payment=...` or config.json's `routes.payment`) — it
381
+ never inherits a bare `default`/env proxy the way every other route does, so an
382
+ egress proxy nobody meant to hand payment tokens to never sees them by accident.
383
+
384
+ `${ENV_VAR}` inside a config.json header value (`proxy_headers`, `forward_headers.add`)
385
+ is expanded from the process environment at resolve time, so a secret can live in
386
+ the environment rather than the file itself. A proxy password can also live in
387
+ `password_ref` (resolved through the same macOS Keychain/Linux Secret Service tiers
388
+ `portage payment` uses, under its own `portage-cli-proxy` service name) instead of
389
+ plaintext in the URL — `portage doctor` warns when it finds a plaintext one anyway.
390
+
391
+ `portage doctor` reports, per route, what's effectively configured (credentials
392
+ redacted), whether each configured proxy/gateway is actually reachable, and flags
393
+ plaintext proxy credentials and an intercepting proxy (`ca_file` or `gateway` mode)
394
+ sitting on the `payment` route.
395
+
396
+ ### WebMCP (library use, opt-in)
397
+
398
+ `portage buy` from the shell has no browser of its own, so there's no CLI
399
+ flag for this — it's for a caller embedding `Portage::Cli::Buy` directly
400
+ alongside its own browser automation:
401
+
402
+ ```ruby
403
+ Portage::Cli::Buy.new(url: "shop.example", query: "mug",
404
+ webmcp_bridge: my_portage_ucp_webmcp_bridge).call
405
+ ```
406
+
407
+ Given a `portage-ucp-webmcp` outbound bridge already pointed at a navigated
408
+ page, `Buy` tries it after native-UCP discovery finds nothing at that URL
409
+ and before falling back to a platform adapter. `webmcp_checkout_mode`
410
+ (`PORTAGE_WEBMCP_CHECKOUT_MODE`, config.json's `webmcp_checkout_mode`)
411
+ controls how it finishes: `express_stop` (default) builds the cart/checkout
412
+ and always hands off — reason `express_stop`, so `--wait`/`orders
413
+ reconcile`/`handoff_spend_mode` all apply exactly as they do to any other
414
+ hand-off. `token` isn't implemented yet; it reports
415
+ `webmcp_token_unsupported` rather than attempting completion. Requires
416
+ `gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
417
+
273
418
  ### Decisions
274
419
 
275
420
  `portage buy` and `portage find` make their judgment calls
@@ -10,6 +10,13 @@ require_relative "decisions"
10
10
  require_relative "confidence_check"
11
11
  require_relative "checkout_handoff"
12
12
  require_relative "notifier"
13
+ require_relative "user_agent"
14
+ require_relative "homepage_fetch"
15
+ require_relative "permissive_authenticator"
16
+ require_relative "handoff_reconciler"
17
+ require_relative "handoff_spend_mode"
18
+ require_relative "webmcp"
19
+ require_relative "webmcp_checkout_mode"
13
20
 
14
21
  module Portage
15
22
  module Cli
@@ -49,13 +56,26 @@ module Portage
49
56
  # rolling cap and velocity limit count. nil (the default) is the
50
57
  # real ~/.portage/transactions.json, the file Dispatcher writes for
51
58
  # own-store purchases.
52
- # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus two injectable collaborators
59
+ # @param max_price [Integer, nil] the most one unit may cost, in minor
60
+ # units (same as Find's). A product priced above it is never picked,
61
+ # even with a --product-id; one with no price to go on still is, as
62
+ # in Find, and the checkout's own total then meets the spend policy.
63
+ # @param webmcp_bridge [#list_tools, #execute_tool, nil] docs/plans/
64
+ # handoff-reconcile.md Phase 4 — a `portage-ucp-webmcp` outbound
65
+ # Bridge (already pointed at a navigated page) an embedding caller
66
+ # already holds. nil (the default, and the only option from the
67
+ # `portage buy` CLI, which has no browser of its own) skips WebMCP
68
+ # entirely — zero behavior change from before this parameter
69
+ # existed. Given one, attempted after native-UCP discovery finds
70
+ # nothing at this URL and before a platform-adapter fallback (see
71
+ # #webmcp_flow).
72
+ # rubocop:disable Metrics/ParameterLists -- all keywords; one per flag, plus injectable collaborators
53
73
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
54
- auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil)
74
+ auto_open: nil, notify_webhook: nil, confidence_check: nil, transaction_log: nil,
75
+ max_price: nil, webmcp_bridge: nil)
55
76
  # rubocop:enable Metrics/ParameterLists
56
77
  raw = url.to_s.strip
57
- raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
58
- @uri = URI.parse(raw)
78
+ @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
59
79
  @query = query
60
80
  @qty = qty
61
81
  @payment_token = payment_token
@@ -66,6 +86,8 @@ module Portage
66
86
  @notify_webhook = notify_webhook
67
87
  @confidence_check = confidence_check
68
88
  @transaction_log = transaction_log
89
+ @max_price = max_price
90
+ @webmcp_bridge = webmcp_bridge
69
91
  @decisions = {}
70
92
  end
71
93
 
@@ -84,6 +106,9 @@ module Portage
84
106
  return native_flow(session) if session
85
107
  end
86
108
 
109
+ webmcp_result = webmcp_flow
110
+ return webmcp_result if webmcp_result
111
+
87
112
  platform = Portage::Ucp::Resolver.detect_platform(body, headers)
88
113
  adapter_flow(platform) || dead_end
89
114
  end
@@ -93,7 +118,7 @@ module Portage
93
118
  # --- Step 1: native UCP manifest ---
94
119
 
95
120
  def discover(url)
96
- Portage::Ucp::Client.discover(url.to_s)
121
+ Portage::Ucp::Client.discover(url.to_s, headers: UserAgent.headers)
97
122
  rescue Portage::Ucp::Client::ManifestShapeError => e
98
123
  # The store *is* running UCP — this client just couldn't parse its
99
124
  # manifest. Distinct from a genuine 404/unreachable host below:
@@ -200,6 +225,64 @@ module Portage
200
225
  match && URI.join(@uri, match[1])
201
226
  end
202
227
 
228
+ # --- Step 1b: WebMCP outbound (docs/plans/handoff-reconcile.md Phase 4) ---
229
+ #
230
+ # Opt-in only: nil unless a caller passed `webmcp_bridge:` (see
231
+ # #initialize), so `portage buy` from the shell — with no browser of
232
+ # its own — sees no behavior change here at all. Attempted after
233
+ # native-UCP discovery finds nothing at this URL and before falling
234
+ # back to a platform adapter, since a WebMCP page, like native UCP,
235
+ # works without this process holding the store's own credentials.
236
+ #
237
+ # `express_stop` (the only implemented mode) builds the cart and
238
+ # checkout, then always hands off rather than attempting
239
+ # #complete_checkout — which a WebMCP page doesn't expose by default
240
+ # in the first place (ToolCatalog leaves it out: it needs a
241
+ # server-side Confirmer swap this gem doesn't have a seam for yet).
242
+ # That hand-off feeds Phases 1-3 completely unchanged: reason
243
+ # `express_stop` reserves a pending record, notifies, and later
244
+ # reconciles exactly like any other hand-off.
245
+ def webmcp_flow
246
+ return nil unless @webmcp_bridge
247
+ return webmcp_not_installed_report unless Portage::Cli::Webmcp.available?
248
+
249
+ session = Portage::Ucp::WebMcp.connect(bridge: @webmcp_bridge)
250
+ return nil unless session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
251
+
252
+ return webmcp_token_unsupported_report if webmcp_checkout_mode == "token"
253
+
254
+ full_buy(session, source: "webmcp", force_handoff: true)
255
+ rescue Portage::Ucp::WebMcp::BridgeError, Portage::Ucp::WebMcp::ToolNotFoundError,
256
+ Portage::Ucp::Client::ServerError => e
257
+ build_report(source: "webmcp", outcome: "webmcp_error", browse: false, checkout: false,
258
+ message: "WebMCP checkout failed: #{e.message}")
259
+ end
260
+
261
+ def webmcp_checkout_mode
262
+ Portage::Cli::WebmcpCheckoutMode.resolve
263
+ end
264
+
265
+ def webmcp_not_installed_report
266
+ build_report(source: "webmcp", outcome: "webmcp_not_installed", browse: false, checkout: false,
267
+ message: "A WebMCP bridge was given but portage-ucp-webmcp isn't installed — " \
268
+ "`gem install portage-ucp-webmcp`.")
269
+ end
270
+
271
+ # `token` mode isn't implemented: it needs `Mcp::Server.build` to swap
272
+ # in a payment-token Confirmer for a WebMCP session (see
273
+ # portage-ucp-webmcp's README, "complete_checkout"), plus PayPal/Stripe
274
+ # agent-token enrollment in `portage payment enroll` — neither exists
275
+ # yet (docs/plans/handoff-reconcile.md Phase 4 is explicitly a sketch
276
+ # pending that design). Fails loudly here rather than silently
277
+ # behaving like `express_stop`, so a caller who configured `token`
278
+ # notices instead of getting a hand-off they didn't ask for.
279
+ def webmcp_token_unsupported_report
280
+ build_report(source: "webmcp", outcome: "webmcp_token_unsupported", browse: false, checkout: false,
281
+ message: "webmcp_checkout_mode=token isn't supported yet — see " \
282
+ "docs/plans/handoff-reconcile.md Phase 4. Use express_stop (the default), or " \
283
+ "complete the checkout via the page's own payment button.")
284
+ end
285
+
203
286
  # --- Step 2/3: platform detection + adapter fallback ---
204
287
 
205
288
  def adapter_flow(platform)
@@ -296,7 +379,11 @@ module Portage
296
379
  # remote store, so shipping-address/rate selection stays loopback-only
297
380
  # for now rather than guessing at a wire shape no real UCP server has
298
381
  # confirmed (see docs/design-log.md).
299
- def full_buy(session, source:, fulfillment_adapter: nil)
382
+ # @param force_handoff [Boolean] docs/plans/handoff-reconcile.md Phase
383
+ # 4's `express_stop` — skips escalation/confirmation/completion
384
+ # entirely and always hands the built checkout off, once past the
385
+ # `--dry-run` check. Only #webmcp_flow ever sets this.
386
+ def full_buy(session, source:, fulfillment_adapter: nil, force_handoff: false)
300
387
  products = safe_search(session)
301
388
  product = select_product(products)
302
389
  unless product
@@ -310,7 +397,7 @@ module Portage
310
397
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
311
398
 
312
399
  warnings = reconcile_checkout(product, checkout)
313
- finish_checkout(session, source, products, checkout, warnings)
400
+ finish_checkout(session, source, products, checkout, warnings, force_handoff: force_handoff)
314
401
  end
315
402
 
316
403
  # A store can silently drop the requested line, change its quantity, or
@@ -406,9 +493,10 @@ module Portage
406
493
  end
407
494
 
408
495
  def no_match_message
409
- return "No product matched \"#{@query}\"." unless @product_id
496
+ budget = @max_price ? " at or under #{@max_price} minor units" : ""
497
+ return "No product matched \"#{@query}\"#{budget}." unless @product_id
410
498
 
411
- "Product #{@product_id} isn't in this store's results for \"#{@query}\"."
499
+ "Product #{@product_id} isn't in this store's results for \"#{@query}\"#{budget}."
412
500
  end
413
501
 
414
502
  # With a --product-id, that exact product or nothing: falling back to the
@@ -420,12 +508,28 @@ module Portage
420
508
  # when an in-stock match sits right below it (confirmed live
421
509
  # 2026-09-23 on allbirds.com and billabong.com). Falls back to the top
422
510
  # hit when nothing reports stock, so the store still gets to answer.
511
+ #
512
+ # Either way, only among products within --max-price: before it
513
+ # reached here, `buy <url> --max-price` checked out whatever the store
514
+ # ranked first (confirmed live 2026-09-24: a $679.95 board on
515
+ # burton.com under --max-price 600).
423
516
  def select_product(products)
517
+ products = products.select { |product| within_max_price?(product) }
424
518
  return products.find { |product| available?(product) } || products.first unless @product_id
425
519
 
426
520
  products.find { |product| product_id_of(product) == @product_id }
427
521
  end
428
522
 
523
+ # Priced the way #reconcile_checkout expects the store to charge: the
524
+ # variant #line_item_id_of would check out, else the product's lowest
525
+ # price.
526
+ def within_max_price?(product)
527
+ return true unless @max_price
528
+
529
+ price = expected_unit_price(product, line_item_id_of(product))
530
+ price.nil? || price <= @max_price
531
+ end
532
+
429
533
  # A product with no variant availability to go on counts as buyable —
430
534
  # only an explicit `available: false` on every variant rules it out.
431
535
  def available?(product)
@@ -462,10 +566,11 @@ module Portage
462
566
  product_id_of(product)
463
567
  end
464
568
 
465
- def finish_checkout(session, source, products, checkout, warnings = [])
569
+ def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
466
570
  escalation = decide_escalation(checkout, warnings)
467
571
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
468
572
  return dry_run_report(source, products, checkout, warnings) if @dry_run
573
+ return webmcp_handoff_report(source, products, checkout, warnings) if force_handoff
469
574
  return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
470
575
 
471
576
  complete(session, source, products, checkout, warnings)
@@ -670,10 +775,23 @@ module Portage
670
775
  # relay and an agent loop branch on the same value.
671
776
  def handoff_report(source, products, checkout, warnings, outcome:, message:)
672
777
  handoff = hand_off(checkout, reason: outcome, source: source, message: message, warnings: warnings)
673
- checkout_report(source, products, checkout, outcome: outcome, warnings: warnings, message: message,
778
+ checkout_report(source, products, checkout, outcome: outcome, message: message,
779
+ warnings: warnings + Array(@pending_handoff_warning),
674
780
  checkout_url: checkout_url_of(checkout), handoff: handoff)
675
781
  end
676
782
 
783
+ # docs/plans/handoff-reconcile.md Phase 4 — #webmcp_flow's
784
+ # `express_stop` mode. Reuses #handoff_report as-is: reason
785
+ # `express_stop` reserves a pending shopper record and fires
786
+ # auto-open/notify exactly like any other hand-off (Phases 1-3 apply
787
+ # unchanged), even though it got here because a mode setting chose to
788
+ # stop, not because anything was denied or escalated.
789
+ def webmcp_handoff_report(source, products, checkout, warnings)
790
+ message = "Cart and checkout are built — finish payment with the store's own express-pay button " \
791
+ "on the page."
792
+ handoff_report(source, products, checkout, warnings, outcome: "express_stop", message: message)
793
+ end
794
+
677
795
  # Never fires on --dry-run (a dry run creates a real checkout but never
678
796
  # attempts completion — auto-opening/notifying over a preview run would
679
797
  # be actively wrong), and never fires without a checkout_url to hand
@@ -685,15 +803,67 @@ module Portage
685
803
  # The webhook body carries the report's own `message`, plus the store
686
804
  # and the shopper's query, so a Slack/Zapier relay can post it as-is
687
805
  # without a lookup back into this process.
806
+ #
807
+ # Also where docs/plans/handoff-reconcile.md Phase 1 records a pending
808
+ # `settled_by: "shopper"` TransactionLog row (best-effort — a failed
809
+ # write surfaces as a warning, never blocks the hand-off itself), and
810
+ # where Phase 2's `precheck` spend mode suppresses auto-open when this
811
+ # checkout's total would already exceed the buyer's own spend cap.
688
812
  def hand_off(checkout, reason:, source:, message:, warnings:)
689
813
  url = checkout_url_of(checkout)
690
814
  return nil if @dry_run || url.nil?
691
815
 
692
- opened = CheckoutHandoff.new(auto_open: @auto_open).call(url)
693
- error = notifier.call(event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
694
- query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
695
- totals: checkout["totals"], warnings: warnings)
696
- { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
816
+ over_cap = precheck_mode? && over_spend_cap?(checkout)
817
+ record_pending_handoff(checkout, reason: reason)
818
+
819
+ opened = over_cap ? false : CheckoutHandoff.new(auto_open: @auto_open).call(url)
820
+ error = notifier.call(handoff_notify_payload(checkout, url, reason: reason, source: source, message: message,
821
+ warnings: warnings, over_cap: over_cap))
822
+ result = { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
823
+ over_cap ? result.merge(over_cap: true) : result
824
+ end
825
+
826
+ def handoff_notify_payload(checkout, url, reason:, source:, message:, warnings:, over_cap:)
827
+ payload = { event: "checkout_handoff", reason: reason, message: message, store: @uri.to_s,
828
+ query: @query, checkout_url: url, checkout_id: checkout["id"], source: source,
829
+ totals: checkout["totals"], warnings: warnings }
830
+ over_cap ? payload.merge(over_cap: true) : payload
831
+ end
832
+
833
+ # Never raises: a record that can't be written is a warning on the
834
+ # report (surfaced via @pending_handoff_warning, see #handoff_report),
835
+ # not a reason to fail the hand-off itself — same posture as
836
+ # #settle_transaction's own @unrecorded_warning.
837
+ def record_pending_handoff(checkout, reason:)
838
+ transaction_log.reserve(idempotency_key: handoff_transaction_key(checkout), checkout_id: checkout["id"],
839
+ shop: @uri.host, payment_token_ref: token_ref, amount: checkout_total(checkout),
840
+ currency: checkout["currency"], settled_by: "shopper", handoff_reason: reason,
841
+ store_url: @uri.to_s, expires_at: checkout["expires_at"])
842
+ rescue StandardError => e
843
+ @pending_handoff_warning = "Couldn't save this hand-off for later reconcile (#{e.message}) — " \
844
+ "run `portage orders reconcile --checkout #{checkout['id']}` once fixed, " \
845
+ "or it'll never be picked up automatically."
846
+ end
847
+
848
+ def handoff_transaction_key(checkout)
849
+ "portage-buy:#{@uri.host}:#{checkout['id']}"
850
+ end
851
+
852
+ def precheck_mode?
853
+ Portage::Cli::HandoffSpendMode.resolve == "precheck"
854
+ end
855
+
856
+ # Same PolicyGuard the buyer's own spend cap already goes through
857
+ # (#decide_policy) — reused here so a hand-off that never reached
858
+ # #decide_policy at all (escalation, permission_denied, no_payment_token,
859
+ # checkout_mismatch) still gets the buyer a heads-up that finishing
860
+ # this checkout by hand would blow the cap, before they do.
861
+ def over_spend_cap?(checkout)
862
+ Portage::Ucp::PolicyGuard.check!(amount: checkout_total(checkout), currency: checkout["currency"],
863
+ merchant: @uri.host, token_ref: token_ref, transaction_log: transaction_log)
864
+ false
865
+ rescue Portage::Ucp::PolicyViolationError
866
+ true
697
867
  end
698
868
 
699
869
  def notifier
@@ -796,23 +966,7 @@ module Portage
796
966
  # catalog-only-native adapter-checkout-fallback path) ---
797
967
 
798
968
  def fetch_homepage(uri, limit = REDIRECT_LIMIT)
799
- return [nil, {}] if limit.zero?
800
-
801
- response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
802
- open_timeout: 5, read_timeout: 5) do |http|
803
- http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
804
- end
805
-
806
- case response
807
- when Net::HTTPRedirection
808
- fetch_homepage(URI.join(uri, response["location"]), limit - 1)
809
- when Net::HTTPSuccess
810
- [response.body, response.to_hash]
811
- else
812
- [nil, {}]
813
- end
814
- rescue StandardError
815
- [nil, {}]
969
+ HomepageFetch.call(uri, limit: limit)
816
970
  end
817
971
 
818
972
  def dead_end
@@ -823,14 +977,6 @@ module Portage
823
977
  def build_report(**fields)
824
978
  { url: @uri.to_s, checkout_url: nil, products: [], warnings: [] }.merge(fields)
825
979
  end
826
-
827
- # Loopback buy against your own store needs *some* authenticator (§9
828
- # rejects anonymous mutation by default) — since this process already
829
- # has this platform's own credentials (that's the gate to even reach
830
- # here), authenticating this local CLI session is reasonable.
831
- class PermissiveAuthenticator < Portage::Ucp::Authenticator
832
- def call(_server_context) = :local_cli
833
- end
834
980
  end
835
981
  end
836
982
  end