portage-cli 0.7.0 → 0.7.4

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: 58834de7ed60ddb98da33896147737f518ccb8d02d58f0d3aeceb645ecef8ea6
4
+ data.tar.gz: 24e471dfb23e48b8ff1f157dbac941442ca84571120ad754451dfb36d9c08d60
5
5
  SHA512:
6
- metadata.gz: 802fb90a17c93986ff7f9101e09e845af467e11607985ddd62cefcd4704f25c5f05292f871c168adeebf386d92620471d6ea6bd8273a74fabb11a3c765324f7d
7
- data.tar.gz: '021008e8975eb3002986f0e9f27d5d776b1da067c3fbc6083909abbecde99a616680d3569c609fae0d87ae402e4002577c8cca6bd62ca4811d95c580d8ba8d10'
6
+ metadata.gz: 70c1a8b9db25d281091e496db0eb7e45de1e54b0afbc943a35866cc9ec2efb4a292b043a0720932ff5f3c2a93757ec3b217785d96ec4f2ece6e637132aed1d04
7
+ data.tar.gz: af42b5fb85cc582d86e60d616f38f177df92b6c53df59a7ccedcd8780855adb15f876e32ddbfda384bd579825ed90f68c5a833fd061ed79fce43e3a2c056edcd
data/CHANGELOG.md CHANGED
@@ -4,6 +4,148 @@ 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.4] - 2026-09-25
8
+
9
+ - **`portage doctor` reports how it was installed, and warns when another
10
+ `portage` shadows it** (`docs/plans/homebrew-distribution.md` Phase 4).
11
+ New findings, all offline and without shelling out to `brew`:
12
+ - `install`: `homebrew` (with the Cellar keg) when this gem or its Ruby
13
+ lives under `HOMEBREW_PREFIX/Cellar/portage/` (`$HOMEBREW_PREFIX`,
14
+ then `/opt/homebrew`, `/usr/local`, `/home/linuxbrew/.linuxbrew`),
15
+ otherwise `gem` with the gem's own path;
16
+ - `runtime`: the Ruby version and `RbConfig.ruby` path, plus the
17
+ `portage-cli` version;
18
+ - `adapters`: each first-party adapter gem (plus `webmcp` and
19
+ `decision`), whether it loads and at which version. A gem install
20
+ without adapters is normal; a Homebrew install missing one is a
21
+ warning, since the formula bundles them all. A broken adapter is
22
+ reported, never raised;
23
+ - `path`: every `portage` on `PATH`, compared by resolved Cellar
24
+ location rather than raw path. Warns when a Homebrew install is
25
+ shadowed by an earlier `portage` (typically a `gem install` copy in a
26
+ mise/rbenv/asdf/rvm Ruby), naming the winner and how to fix it, and
27
+ when a gem install is shadowed by a Homebrew one.
28
+ - `portage doctor` also warns when the `PORTAGE_SHIP_*` address is missing
29
+ or incomplete, naming the missing variables. Without
30
+ `PORTAGE_SHIP_COUNTRY` a native UCP store gets no buyer context, which
31
+ is how a live Shopify store ended up reporting in-stock items as out of
32
+ stock.
33
+ - `doctor` findings now carry a `level` (`warning` or `info`) and, for the
34
+ new checks, structured `details`, both in `--json`. The JSON is still a
35
+ top-level array. Only warnings make doctor exit 1; the text output lists
36
+ info findings first, then the warnings or `No issues found.`.
37
+ - `ShippingProfile` treats an empty `PORTAGE_SHIP_*` value as unset, the
38
+ way `BuyerContext` and the WooCommerce billing fallback already did, so
39
+ a `.env` copied from `.env.example` with blanks left in no longer
40
+ submits an address of empty strings.
41
+
42
+ ## [0.7.3] - 2026-09-25
43
+
44
+ - **Proxy support** (`docs/plans/proxy-support.md` Phases 2-3). Every
45
+ network command (`buy`, `find`, `compare`, `doctor`, `payment enroll`)
46
+ takes `--proxy`, `--proxy-mode`, `--proxy-header`, `--no-proxy`,
47
+ `--proxy-route`, `--proxy-chain`, `--proxy-passthrough`, `--proxy-ca` and
48
+ `--no-env-proxy`, resolved per field against `PORTAGE_PROXY*` env vars
49
+ and `~/.portage/config.json`'s `proxy` section into core's
50
+ `Support::ProxyConfig`. `proxy.password_ref` resolves through Keychain or
51
+ Secret Service (service `portage-cli-proxy`). The payment route stays
52
+ direct unless a proxy is named for it explicitly, so payment traffic no
53
+ longer inherits a bare `$http_proxy`/`$https_proxy`. `portage doctor`
54
+ probes each configured proxy through `Support::Connection` and warns on
55
+ plaintext credentials in `config.json`.
56
+ - Requires `portage-ucp` `~> 0.10` (for `Support::Connection` and
57
+ `ProxyConfig`) and `portage-ucp-client` `>= 0.6.3` (for
58
+ `Client::USER_AGENT`, which the default User-Agent is built from). Both
59
+ floors were too low before: against `portage-ucp` 0.9.0 or
60
+ `portage-ucp-client` 0.6.2, `require "portage/cli"` raised `NameError`.
61
+ - `portage --version` (also `-v` and `portage version`) prints the gem's
62
+ version and exits 0. Needed so a packaged install — the Homebrew
63
+ formula's offline `test do` block, or anyone else scripting around a
64
+ release — can confirm which build is on `PATH` without hitting the
65
+ network.
66
+ - **`portage-console` failed to start on Ruby 4.0.** It requires `irb`,
67
+ which stopped being a default gem in Ruby 4.0 (it's a bundled gem now),
68
+ and the gemspec never declared it, so any isolated install
69
+ (Homebrew's, or anything under Bundler) raised `LoadError: cannot load
70
+ such file -- irb`. `irb` is now a runtime dependency.
71
+ - **Did the shopper finish the checkout? `portage orders reconcile`.**
72
+ Every checkout `portage buy` hands to the shopper's own browser
73
+ (`requires_escalation`, `permission_denied`, `no_payment_token`,
74
+ `policy_blocked`, `low_confidence`, `checkout_mismatch`) now reserves a
75
+ pending record in the transaction log (best-effort — a failed write is a
76
+ report warning, never blocks the hand-off). `portage orders reconcile
77
+ [--checkout ID] [--json]` re-fetches each pending checkout from the store
78
+ and settles it: `complete` only on a store-reported `completed` status,
79
+ `failed` on `canceled` or an unanswered expiry, otherwise stays pending.
80
+ Never infers success from a vanished checkout or a plain timeout. Safe to
81
+ run from cron/launchd. See `docs/plans/handoff-reconcile.md`.
82
+ - **`handoff_spend_mode`** (`PORTAGE_HANDOFF_SPEND_MODE`, config.json) —
83
+ whether a reconciled shopper purchase counts toward the buyer's own spend
84
+ cap/velocity limit. `block` (default): counts like any agent purchase.
85
+ `warn`: recorded but excluded from cap math. `precheck`: `block`, plus a
86
+ spend-cap check at hand-off time that suppresses auto-open (never the
87
+ URL) when this checkout would already exceed the cap.
88
+ - Phase 0 of the plan above (a live signal check against a real store) was
89
+ not run before this shipped — see `docs/design-log.md` §44.
90
+ - **`portage buy --wait [--wait-timeout DURATION|off]`.** After a hand-off,
91
+ polls `HandoffReconciler` with backoff (2s → 30s, plus jitter) until the
92
+ checkout settles or its deadline passes — the earlier of
93
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json;
94
+ default 30m, `off` removes it) and the checkout's own `expires_at`.
95
+ Ctrl-C or the deadline leaves the record pending for a later `portage
96
+ orders reconcile`; it never settles from the wait itself. Under `--wait
97
+ --json`, stdout streams NDJSON (`handoff`, `handoff_status` on each
98
+ store-reported status change, `handoff_settled`) followed by the final
99
+ report object; plain `--json` with no `--wait` is unchanged byte-for-byte.
100
+ - **`reconcile_notify`** (`PORTAGE_RECONCILE_NOTIFY`, config.json) — a comma
101
+ list of channels a settled hand-off notifies on, from `--wait` or `portage
102
+ orders reconcile`. Default `webhook`. Adds `macos` (a native notification,
103
+ merchant/amount escaped into a fixed AppleScript template) and `terminal`
104
+ (a printed line, forced on for a plain-text `--wait` regardless of
105
+ configuration). `journal` is accepted for documentation — the Phase 1
106
+ order-snapshot journal write was already unconditional.
107
+ - **WebMCP outbound, opt-in (Phase 4, partial).** `Buy.new(webmcp_bridge:)`
108
+ takes a `portage-ucp-webmcp` outbound bridge already pointed at a
109
+ navigated page; when given one, `portage-cli` tries it after native-UCP
110
+ discovery finds nothing and before a platform-adapter fallback. Only
111
+ `webmcp_checkout_mode: express_stop` (the default) is implemented: it
112
+ builds the cart/checkout and always hands off (reason `express_stop`),
113
+ feeding Phases 1-3 unchanged, since a WebMCP page doesn't expose
114
+ `complete_checkout` by default anyway. `token` mode reports
115
+ `webmcp_token_unsupported` rather than attempting completion — it still
116
+ needs a `Confirmer`-swap seam in `Mcp::Server.build` and payment-token
117
+ enrollment that don't exist yet. No new hard runtime dependency:
118
+ `portage-ucp-webmcp` is lazily `require`d, same posture as an optional
119
+ platform adapter gem.
120
+
121
+ ## [0.7.2] - 2026-09-24
122
+
123
+ - The User-Agent every store-facing request sends (`Cli::UserAgent`, née
124
+ `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`) is now configurable: `PORTAGE_USER_AGENT`
125
+ beats `~/.portage/config.json`'s `user_agent` key, both of which beat the
126
+ `portage-cli/<ver> portage-ucp-client/<ver> (+https://github.com/...)`
127
+ default — same precedence `Notifier`'s webhook URL already uses. `portage
128
+ doctor` (and its `configure`/`setup` aliases) now flags a configured value
129
+ containing a stray newline, since `Net::HTTP` would otherwise raise on it
130
+ mid-checkout instead of at setup time.
131
+
132
+ ## [0.7.1] - 2026-09-24
133
+
134
+ - Fix: `buy <url> --max-price` (and `--product-id` with `--max-price`) was
135
+ silently ignored — `add_search_options` only wired `--max-price` into the
136
+ `find` options, not `buy`, so a URL-driven buy could complete over the cap
137
+ the caller set. `Buy#select_product` now filters to products within
138
+ `max_price` first, using a variant's own price where available and
139
+ otherwise the product's lowest price (unpriced products stay eligible,
140
+ same rule `Find` already used). `no_match_message` now names the cap.
141
+ - Every store-facing request (`discover()` calls and the notifier webhook
142
+ POST) now sends `portage-cli/<ver> portage-ucp-client/<ver>
143
+ (+https://github.com/tomtom87/Portage)` instead of Ruby's or Faraday's
144
+ default User-Agent. New `Cli::USER_AGENT`/`Cli::HTTP_HEADERS`
145
+ (`lib/portage/cli/user_agent.rb`) replace the ad-hoc UA strings each of
146
+ `portage-buy`, `portage-find` and `portage-payment-enroll` built on their
147
+ own. Requires `portage-ucp-client >= 0.6.3`.
148
+
7
149
  ## [0.7.0] - 2026-09-23
8
150
 
9
151
  - **The decision layer is wired in.** `buy`/`find` make their judgment
data/README.md CHANGED
@@ -49,27 +49,83 @@ No single adapter gem is a hard dependency — install whichever
49
49
 
50
50
  ## Installation
51
51
 
52
+ **Homebrew** (macOS and Linux) — recommended for using the CLI:
53
+
54
+ ```bash
55
+ brew install tomtom87/portage/portage
56
+ ```
57
+
58
+ The formula installs `portage-cli` plus every adapter gem (Shopify, Wix,
59
+ WooCommerce, BigCommerce, Magento, Etsy, Instagram, WebMCP, Decision) into
60
+ its own directory, running on Homebrew's own `ruby`, so it doesn't depend on
61
+ or change whichever Ruby you use for anything else. It gives you `portage`
62
+ and `portage-console`.
63
+
64
+ **RubyGems** — on any Ruby ≥ 3.2, or when you only want some adapters:
65
+
66
+ ```bash
67
+ gem install portage-cli
68
+ gem install portage-ucp-shopify # optional: add only the adapters you need
69
+ ```
70
+
71
+ In an app's `Gemfile` instead:
72
+
52
73
  ```ruby
53
- # Gemfile
54
74
  gem "portage-cli"
55
75
  ```
56
76
 
77
+ ### Upgrading
78
+
79
+ - Homebrew: `brew upgrade portage`. Your config and data in `~/.portage`
80
+ (policy, payment-method metadata, transaction log, order ledger,
81
+ `config.json`) are left alone, as they are by `brew uninstall`.
82
+ - RubyGems: `gem update portage-cli` (and any adapter gems you added).
83
+
84
+ Portage has no self-update command, by design: whichever tool installed it
85
+ owns upgrades.
86
+
87
+ ### Linux: stored secrets need `secret-tool`
88
+
89
+ On Linux, stored payment tokens (`portage payment enroll`) and proxy
90
+ passwords (`password_ref`) live in the Secret Service (GNOME Keyring,
91
+ KWallet) via the `secret-tool` command. It comes from your distribution,
92
+ not from the formula or the gem:
93
+
57
94
  ```bash
58
- bundle install
95
+ sudo apt install libsecret-tools # Debian/Ubuntu
96
+ sudo dnf install libsecret # Fedora
59
97
  ```
60
98
 
61
- Or standalone:
99
+ Without it (or without a live D-Bus session, e.g. over SSH or in CI),
100
+ Portage uses the headless tier: the token comes from
101
+ `PORTAGE_PAYMENT_TOKEN` and nothing is stored locally. On macOS the
102
+ Keychain is used and nothing extra is needed.
103
+
104
+ ### Two copies on PATH
105
+
106
+ A `gem install` copy and a Homebrew copy can both be installed, and your
107
+ shell runs whichever `portage` comes first on `PATH`. A common case is an
108
+ old gem copy in a mise, rbenv, asdf or rvm Ruby's `bin`, which then keeps
109
+ running after `brew install` or `brew upgrade`. `portage doctor` warns
110
+ about this. To check:
62
111
 
63
112
  ```bash
64
- gem install portage-cli
113
+ which -a portage
65
114
  ```
66
115
 
116
+ To fix it, keep one copy: `gem uninstall portage-cli` (with the Ruby that
117
+ owns the gem copy active) to use Homebrew's, or `brew uninstall portage` to
118
+ use the gem's. Or reorder `PATH` so the one you want comes first.
119
+
67
120
  ## Usage
68
121
 
122
+ <!-- usage-start -->
69
123
  ```bash
70
124
  portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
71
- [--yes] [--dry-run] [--decision-backend jev|laya]
125
+ [--yes] [--dry-run] [--auto-open|--no-auto-open]
126
+ [--notify-webhook URL] [--decision-backend jev|laya]
72
127
  [--min-confidence N] [--json]
128
+ [--wait [--wait-timeout DURATION|off]]
73
129
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
74
130
  portage find --query "..." [--max-price N] [--limit N] [--json]
75
131
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -88,7 +144,24 @@ portage policy set [--per-transaction-cap N --currency CUR]
88
144
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
89
145
  [--velocity-count N --velocity-window-seconds N]
90
146
  [--allow HOST ...] [--clear-allowlist]
147
+ portage orders reconcile [--checkout ID] [--json]
148
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # aliases: configure, setup
149
+ portage generate adapter NAME [--dir DIR]
150
+ portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
151
+ portage --version
91
152
  ```
153
+ <!-- usage-end -->
154
+
155
+ `buy`/`find`/`compare`/`doctor`/`payment enroll` (the network-touching commands —
156
+ `orders reconcile` doesn't take these) also accept:
157
+
158
+ ```
159
+ [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
160
+ [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
161
+ [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
162
+ ```
163
+
164
+ See "Proxy" below.
92
165
 
93
166
  - `--query` — search term. Against the store's catalog when you name a store,
94
167
  against the search backends when you don't.
@@ -102,7 +175,10 @@ portage policy set [--per-transaction-cap N --currency CUR]
102
175
  search ranks first. If the id isn't in the results, nothing is bought.
103
176
  - `--store` — name the merchant without giving a full URL; skips the search.
104
177
  - `--max-price` — in major units (`400` means 400), compared per offer in that
105
- offer's own currency. No FX conversion.
178
+ offer's own currency. No FX conversion. Applies per unit, to the search and
179
+ to the store's own catalog once one is settled (a URL, `--store`, or a
180
+ picked offer): nothing priced above it is checked out, even with
181
+ `--product-id`. A product with no price is still eligible.
106
182
  - `--limit` — how many candidate stores to probe, capped at 12.
107
183
  - `--yes` — skip the confirmation prompt before completing checkout.
108
184
  - `--dry-run` — resolve and price the order without completing checkout.
@@ -270,6 +346,166 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
270
346
  limits bound to one enrolled card) are set via `portage payment enroll
271
347
  --scope-*` above, not here.
272
348
 
349
+ ### Orders reconcile
350
+
351
+ Nearly every real checkout `portage buy` can't finish itself hands the
352
+ shopper a link — `requires_escalation`, `permission_denied`,
353
+ `no_payment_token`, `policy_blocked`, `low_confidence`, or
354
+ `checkout_mismatch`. That's a pending purchase this process knows nothing
355
+ more about until something asks the store. `portage orders reconcile`
356
+ re-fetches each pending hand-off from the store and settles it once the
357
+ store itself reports a terminal status:
358
+
359
+ ```bash
360
+ portage orders reconcile # every pending hand-off
361
+ portage orders reconcile --checkout chk_123
362
+ portage orders reconcile --json
363
+ ```
364
+
365
+ A `completed` checkout settles `complete` — using the store's own total at
366
+ settle time, not the hand-off-time snapshot — records the order (when one
367
+ exists) and a journal entry, and is counted toward your spend policy's
368
+ rolling cap/velocity per `handoff_spend_mode` below. A `canceled` checkout,
369
+ or one that expires with no answer, settles `failed`. Anything still
370
+ in-progress, or not currently reachable, is left pending for the next run —
371
+ safe to put on a cron/launchd schedule; two overlapping runs never
372
+ double-settle the same record.
373
+
374
+ `handoff_spend_mode` (`PORTAGE_HANDOFF_SPEND_MODE`, config.json's
375
+ `handoff_spend_mode`) controls whether a reconciled shopper purchase counts
376
+ toward the caps `portage policy set` configures:
377
+
378
+ - `block` (default) — counts like any agent-completed purchase.
379
+ - `warn` — recorded, but excluded from the cap/velocity math; still notifies
380
+ when it would have pushed spend over the cap.
381
+ - `precheck` — `block`, plus a spend-cap check at hand-off time
382
+ (`portage buy`, not reconcile): if this checkout's total would already
383
+ exceed your cap, the URL is still printed but auto-open is suppressed.
384
+
385
+ `--wait [--wait-timeout DURATION|off]` on `portage buy` polls the same
386
+ reconciler right after a hand-off, instead of waiting for a separate
387
+ `orders reconcile` run. It backs off (2s → 30s, plus jitter) until the
388
+ checkout settles or its deadline passes — the earlier of
389
+ `handoff_wait_timeout` (`PORTAGE_HANDOFF_WAIT_TIMEOUT`, config.json; default
390
+ `30m`, `off` removes it) and the checkout's own `expires_at`. Ctrl-C, or
391
+ the deadline, leaves the record pending for a later `portage orders
392
+ reconcile` — it never settles from the wait itself. Under `--wait --json`,
393
+ stdout streams NDJSON — `handoff`, `handoff_status` on each store-reported
394
+ status change, `handoff_settled` — followed by the final report object;
395
+ plain `--json` without `--wait` is unchanged.
396
+
397
+ `reconcile_notify` (`PORTAGE_RECONCILE_NOTIFY`, config.json's
398
+ `reconcile_notify`) is a comma list of channels a settled hand-off notifies
399
+ on, from `--wait` or `orders reconcile`: `webhook` (default), `macos` (a
400
+ native notification), `terminal` (a printed line — forced on for a
401
+ plain-text `--wait` regardless of configuration), and `journal` (the order
402
+ snapshot journal write, already unconditional — listing it just documents
403
+ that).
404
+
405
+ ### Doctor
406
+
407
+ ```bash
408
+ portage doctor # aliases: portage configure, portage setup
409
+ portage doctor --json
410
+ ```
411
+
412
+ Checks this machine's setup without touching the network (apart from
413
+ probing any proxy you've configured). It first reports how Portage is
414
+ installed, then lists anything that needs fixing:
415
+
416
+ - `install`: `homebrew` (with the Cellar path) or `gem` (with the gem's
417
+ path).
418
+ - `runtime`: the Ruby version and path it runs on, and the `portage-cli`
419
+ version.
420
+ - `adapters`: which first-party adapter gems load, and at which version.
421
+ Missing adapters are expected on a gem install; on Homebrew, which
422
+ bundles them all, a missing one is a warning.
423
+ - `path`: which `portage` your shell actually runs. Warns when another copy
424
+ earlier on `PATH` shadows this one (see "Two copies on PATH" above).
425
+ - `shipping`: warns when `PORTAGE_SHIP_*` is missing or incomplete (see
426
+ "Shipping address" below), naming the variables to set.
427
+ - Seller-side checks against `Portage::Ucp.configuration` (authenticator,
428
+ rate limiter, signing keys, payment handlers; pass `--require` to load
429
+ your app's initializer first), the confidence gate's backend, the
430
+ User-Agent, and proxy settings.
431
+
432
+ With `--json` the output is an array of findings, each with `check`,
433
+ `message`, `level` (`warning` or `info`) and, for the install checks,
434
+ `details`. Doctor exits `1` when there's at least one warning, else `0`.
435
+
436
+ ### Proxy
437
+
438
+ `buy`, `find`, `compare`, `doctor`, and `payment enroll` accept the flags below,
439
+ resolved (per field, flag > env > `~/.portage/config.json`) into a
440
+ `Portage::Ucp::Support::ProxyConfig` by `Portage::Cli::ProxySettings` — see
441
+ [`docs/proxy.md`](../docs/proxy.md) for corporate egress, a rotating residential
442
+ pool, an API gateway, mitmproxy for debugging, and nginx/Cloudflare in front of the
443
+ MCP/WebMCP endpoints, worked through end to end.
444
+
445
+ | Flag | Meaning |
446
+ | --- | --- |
447
+ | `--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. |
448
+ | `--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`. |
449
+ | `--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. |
450
+ | `--no-proxy HOSTS` | Comma-separated hostnames/suffixes/CIDRs (or a bare `*`) to always bypass the proxy for, whatever the route resolves to. |
451
+ | `--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`. |
452
+ | `--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. |
453
+ | `--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. |
454
+ | `--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. |
455
+ | `--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. |
456
+
457
+ **Env vars** (checked when the matching flag is absent, before config.json):
458
+
459
+ | Var | Matches |
460
+ | --- | --- |
461
+ | `PORTAGE_PROXY` | `--proxy` |
462
+ | `PORTAGE_PROXY_MODE` | `--proxy-mode` |
463
+ | `PORTAGE_NO_PROXY` | `--no-proxy` |
464
+ | `PORTAGE_PROXY_CA` | `--proxy-ca` |
465
+ | `PORTAGE_PROXY_HEADERS` | A JSON object of header name → value, merged under any `--proxy-header` flags (flags win on a name collision). |
466
+
467
+ Below all of the above, the standard `HTTPS_PROXY`/`HTTP_PROXY`/`NO_PROXY` (and
468
+ lowercase) variables are still the fallback for any route nothing here configures
469
+ at all — existing environments keep working unless `--no-env-proxy` is set. The
470
+ **`payment` route is always forced `direct`** unless a proxy is named for it
471
+ explicitly (`--proxy-route payment=...` or config.json's `routes.payment`) — it
472
+ never inherits a bare `default`/env proxy the way every other route does, so an
473
+ egress proxy nobody meant to hand payment tokens to never sees them by accident.
474
+
475
+ `${ENV_VAR}` inside a config.json header value (`proxy_headers`, `forward_headers.add`)
476
+ is expanded from the process environment at resolve time, so a secret can live in
477
+ the environment rather than the file itself. A proxy password can also live in
478
+ `password_ref` (resolved through the same macOS Keychain/Linux Secret Service tiers
479
+ `portage payment` uses, under its own `portage-cli-proxy` service name) instead of
480
+ plaintext in the URL — `portage doctor` warns when it finds a plaintext one anyway.
481
+
482
+ `portage doctor` reports, per route, what's effectively configured (credentials
483
+ redacted), whether each configured proxy/gateway is actually reachable, and flags
484
+ plaintext proxy credentials and an intercepting proxy (`ca_file` or `gateway` mode)
485
+ sitting on the `payment` route.
486
+
487
+ ### WebMCP (library use, opt-in)
488
+
489
+ `portage buy` from the shell has no browser of its own, so there's no CLI
490
+ flag for this — it's for a caller embedding `Portage::Cli::Buy` directly
491
+ alongside its own browser automation:
492
+
493
+ ```ruby
494
+ Portage::Cli::Buy.new(url: "shop.example", query: "mug",
495
+ webmcp_bridge: my_portage_ucp_webmcp_bridge).call
496
+ ```
497
+
498
+ Given a `portage-ucp-webmcp` outbound bridge already pointed at a navigated
499
+ page, `Buy` tries it after native-UCP discovery finds nothing at that URL
500
+ and before falling back to a platform adapter. `webmcp_checkout_mode`
501
+ (`PORTAGE_WEBMCP_CHECKOUT_MODE`, config.json's `webmcp_checkout_mode`)
502
+ controls how it finishes: `express_stop` (default) builds the cart/checkout
503
+ and always hands off — reason `express_stop`, so `--wait`/`orders
504
+ reconcile`/`handoff_spend_mode` all apply exactly as they do to any other
505
+ hand-off. `token` isn't implemented yet; it reports
506
+ `webmcp_token_unsupported` rather than attempting completion. Requires
507
+ `gem install portage-ucp-webmcp` — not a hard dependency of `portage-cli`.
508
+
273
509
  ### Decisions
274
510
 
275
511
  `portage buy` and `portage find` make their judgment calls
@@ -366,12 +602,11 @@ gives up after 5 seconds and reports `notify_error` instead.
366
602
  `portage find`'s offer order comes from `Support::OfferRanking`: buyable
367
603
  first, then cheapest, then unpriced.
368
604
 
369
- ### Shipping address (own-store checkouts only)
605
+ ### Shipping address
370
606
 
371
- When buying against your own store (`portage buy`'s step 2 adapter-credentials
372
- fallback, described at the top of this file) and that adapter supports
373
- `dev.ucp.shopping.fulfillment`, set a default shipping address via env
374
- rather than a flag, same posture as adapter credentials:
607
+ Set your shipping address via env rather than a flag, the same way as
608
+ adapter credentials (`.env.example` at the repo root lists them all).
609
+ `portage doctor` warns until the required ones are set:
375
610
 
376
611
  ```bash
377
612
  export PORTAGE_SHIP_STREET="1 Main St"
@@ -384,12 +619,26 @@ export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
384
619
  export PORTAGE_SHIP_PHONE="+1..." # optional
385
620
  ```
386
621
 
387
- `street`/`city`/`country`/`postal_code` are required — a partial profile is
388
- treated as no profile at all. Once the merchant prices shipping options
389
- against that address, `portage buy` auto-picks the cheapest per fulfillment
390
- group; there's no interactive rate picker, since this drives one automated
391
- purchase. Native (non-adapter) UCP stores don't get this yet — see
392
- `portage-ucp`'s design log for why.
622
+ `street`/`city`/`country`/`postal_code` are required — a partial profile
623
+ (or one with empty values) is treated as no profile at all.
624
+
625
+ The variables are used in two ways:
626
+
627
+ - **Native UCP stores** (`buy` and `find` over HTTP) get
628
+ `PORTAGE_SHIP_COUNTRY`, `_REGION` and `_POSTAL_CODE` (plus
629
+ `PORTAGE_CURRENCY` and `PORTAGE_LANGUAGE`, if set) as UCP buyer context,
630
+ which a store uses to pick the market it prices and stocks in. These
631
+ work on their own, without the full address.
632
+ Without at least the country, a live Shopify store can report in-stock
633
+ items as out of stock.
634
+ - **Your own store** (`portage buy`'s adapter-credentials fallback,
635
+ described at the top of this file), when its adapter supports
636
+ `dev.ucp.shopping.fulfillment`, also gets the full address as the
637
+ checkout's shipping destination. Once the merchant prices shipping
638
+ options against it, `portage buy` auto-picks the cheapest per fulfillment
639
+ group; there's no interactive rate picker, since this drives one
640
+ automated purchase. Native (non-adapter) UCP stores don't get the full
641
+ address yet — see `portage-ucp`'s design log for why.
393
642
 
394
643
  ## Buying without a URL
395
644