portage-cli 0.6.4 → 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 +4 -4
- data/CHANGELOG.md +216 -0
- data/README.md +263 -7
- data/lib/portage/cli/buy.rb +529 -104
- data/lib/portage/cli/checkout_handoff.rb +6 -18
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +104 -1
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/handoff_reconciler/wire_adapters.rb +50 -0
- data/lib/portage/cli/handoff_reconciler.rb +249 -0
- data/lib/portage/cli/handoff_spend_mode.rb +28 -0
- data/lib/portage/cli/handoff_wait_timeout.rb +49 -0
- data/lib/portage/cli/handoff_waiter.rb +103 -0
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/homepage_fetch.rb +41 -0
- data/lib/portage/cli/macos_notifier.rb +37 -0
- data/lib/portage/cli/notifier.rb +26 -27
- data/lib/portage/cli/payment_methods.rb +6 -4
- data/lib/portage/cli/permissive_authenticator.rb +16 -0
- data/lib/portage/cli/proxy_doctor.rb +149 -0
- data/lib/portage/cli/proxy_settings/password_ref.rb +48 -0
- data/lib/portage/cli/proxy_settings.rb +350 -0
- data/lib/portage/cli/reconcile_notifier.rb +65 -0
- data/lib/portage/cli/reconcile_notify.rb +38 -0
- data/lib/portage/cli/search_backends.rb +6 -4
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/user_agent.rb +39 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +25 -0
- data/lib/portage/cli/webmcp_checkout_mode.rb +28 -0
- data/lib/portage/cli.rb +367 -27
- metadata +46 -5
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 996ddc0acd1ec2676257f402fbb03148e8a38c40af9d5cf4fbe50d176416a334
|
|
4
|
+
data.tar.gz: a451d4e8130b1891fb2630d9b5e9edc1efde9486afda2ca3ff863342943d4a09
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: dd25c1c868d3e70f6ef469975f0d2b24f478c1f96bf83a739d2e4a473c38d2695fbd3586e0243ac21f9d75562b080413ad173c18924024ff1135279cfe8622be
|
|
7
|
+
data.tar.gz: e8578d6935466578e44e9e4f2e2caae61e1b9a1b2b52051934989fd28ec8ea9a163d943e3f68aac3704bdae72557d600f4694c7c81b8e9fe00b16685640a10b6
|
data/CHANGELOG.md
CHANGED
|
@@ -4,6 +4,222 @@ 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
|
+
|
|
114
|
+
## [0.7.0] - 2026-09-23
|
|
115
|
+
|
|
116
|
+
- **The decision layer is wired in.** `buy`/`find` make their judgment
|
|
117
|
+
calls through the same rules `portage-ucp-decision` wraps. Ranking,
|
|
118
|
+
escalation and the policy check live in `portage-ucp` core
|
|
119
|
+
(`Support::OfferRanking`, `Support::Escalation`, `PolicyGuard`), so they
|
|
120
|
+
run the same with or without that gem. `portage-ucp-decision` stays an
|
|
121
|
+
optional plugin that only the confidence gate needs. Every checkout
|
|
122
|
+
report carries the verdicts under `decisions:` (`escalation`, `policy`,
|
|
123
|
+
and `confidence` when enabled). Requires `portage-ucp ~> 0.9`.
|
|
124
|
+
- `find` ranks offers buyable first, then cheapest, then unpriced. The
|
|
125
|
+
order is unchanged.
|
|
126
|
+
- When a checkout both requires escalation and mismatches the request
|
|
127
|
+
under `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`, `buy` now reports the
|
|
128
|
+
store's escalation, not the mismatch. Both hand off the checkout.
|
|
129
|
+
- `buy --yes` now checks your spend policy before completing, on remote
|
|
130
|
+
native-UCP stores too. Before, only the own-store flow's in-process
|
|
131
|
+
`Dispatcher` enforced it, so a remote store never saw your caps,
|
|
132
|
+
allowlist or token scopes. A blocked purchase hands off the checkout.
|
|
133
|
+
- New opt-in confidence gate: `--decision-backend jev|laya` /
|
|
134
|
+
`PORTAGE_DECISION_BACKEND`, with a threshold from `--min-confidence` /
|
|
135
|
+
`PORTAGE_MIN_CONFIDENCE` (default `0.8`). Needs `portage-ucp-decision`.
|
|
136
|
+
A low score, a backend that can't answer, or a missing gem holds the
|
|
137
|
+
purchase and hands off the checkout.
|
|
138
|
+
- Every verdict carries a `reason`: a string, or null when the gate
|
|
139
|
+
passed, the same in-process as in `--json`. `confidence.reason` is
|
|
140
|
+
`below_threshold`, `backend_error` or `not_installed`, and
|
|
141
|
+
`confidence.error` holds the detail behind the last two.
|
|
142
|
+
- **`buy` checked out sold-out items when in-stock matches were right
|
|
143
|
+
there.** It took the top search hit and its first variant unconditionally,
|
|
144
|
+
so a sold-out top hit dead-ended on the store's "Sold out" refusal
|
|
145
|
+
(allbirds.com and billabong.com, 2026-09-23). It now takes the first hit
|
|
146
|
+
with stock, and that product's first in-stock variant. When nothing
|
|
147
|
+
reports stock it still falls back to the top hit, and `--product-id`
|
|
148
|
+
still buys exactly that product.
|
|
149
|
+
- `doctor` accepts `TYPESAFE_API_KEY` in place of `JEV_API_KEY`, matching
|
|
150
|
+
`portage-ucp-decision`'s Jev backend.
|
|
151
|
+
- **An agent couldn't tell a held purchase from a bought one without reading
|
|
152
|
+
prose.** `decisions:` only covers the three gates, so a missing payment
|
|
153
|
+
token, a permission-denied store, a dry run or a missing `--yes` all
|
|
154
|
+
showed `escalate: false, allowed: true`. Every `buy` report now carries
|
|
155
|
+
`outcome` (`purchased`, `no_payment_token`, `policy_blocked`, ... — the
|
|
156
|
+
full list is in the README), and the text output leads with it as
|
|
157
|
+
`[outcome]`. Checkout reports also carry `items`, what the checkout
|
|
158
|
+
actually holds, alongside `products`, the search results.
|
|
159
|
+
- **History recorded the wrong things.**
|
|
160
|
+
- A purchase entry listed all ten search results, not what was bought,
|
|
161
|
+
and had no way to tell a completed purchase from a dry run or a held
|
|
162
|
+
one. Entries now carry `outcome`, `items`, `total`/`currency`,
|
|
163
|
+
`checkout_id`, `source`, and the `checkout_url` for anything not
|
|
164
|
+
completed.
|
|
165
|
+
- A `buy` whose search matched nothing was logged as a purchase. It's now
|
|
166
|
+
a search at that store, as are browse-only and dead-end buys, which
|
|
167
|
+
weren't logged anywhere.
|
|
168
|
+
- The search behind a `buy` with no URL wasn't logged at all.
|
|
169
|
+
- **Checkout hand-off webhooks.** The body now includes the report's
|
|
170
|
+
`message`, the `store` and the shopper's `query`, so a Slack/Zapier relay
|
|
171
|
+
can post it without a lookup. A plain-text 2xx (Slack's `ok`) was
|
|
172
|
+
reported as a failed delivery, because the reply was parsed as JSON; any
|
|
173
|
+
2xx now counts. The POST times out after 5s instead of stalling the buy
|
|
174
|
+
for up to two minutes.
|
|
175
|
+
- **The confidence gate could crash a buy after the checkout existed.** Only
|
|
176
|
+
`Decision::Error` was rescued, so a backend's timeout, parse error or
|
|
177
|
+
missing binary escaped as a backtrace with no report, hand-off or history
|
|
178
|
+
entry. Any failure now holds the purchase like a low score does.
|
|
179
|
+
- **The spend policy failed open on a checkout with no total.** PolicyGuard
|
|
180
|
+
skips both caps without an amount, so `buy --yes` completed past a
|
|
181
|
+
configured cap while reporting `allowed: true`. It's now denied as
|
|
182
|
+
`total_unknown` whenever a cap is set.
|
|
183
|
+
- `doctor` checks the confidence backend you selected
|
|
184
|
+
(`PORTAGE_DECISION_BACKEND`): the gem missing, an unknown name, a missing
|
|
185
|
+
`JEV_API_KEY` or Laya bridge, or a bad `PORTAGE_MIN_CONFIDENCE`. It no
|
|
186
|
+
longer asks for `JEV_API_KEY` when no backend is selected.
|
|
187
|
+
- The no-buyer-context warning names every variable it reads and says to
|
|
188
|
+
set at least `PORTAGE_SHIP_COUNTRY`.
|
|
189
|
+
- **Settings resolve one way everywhere.** The auto-open toggle, the
|
|
190
|
+
notify webhook, `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` and the confidence
|
|
191
|
+
gate's backend/threshold each parsed their own env var and repeated the
|
|
192
|
+
"flag, then env var, then `config.json`" order. They now share
|
|
193
|
+
`Portage::Cli::Setting`. Two edge cases change:
|
|
194
|
+
- A blank flag or env var (`PORTAGE_AUTO_OPEN_CHECKOUT=`,
|
|
195
|
+
`--notify-webhook ""`) is unset and falls through to the next level.
|
|
196
|
+
Before, an empty auto-open env var turned auto-open off, and an empty
|
|
197
|
+
`--notify-webhook` turned the webhook off.
|
|
198
|
+
- A `config.json` `"auto_open_checkout"` string is read like the env var,
|
|
199
|
+
so `"no"` means off. Before, any non-empty value meant on.
|
|
200
|
+
- Report totals and the policy check's amount come from core's
|
|
201
|
+
`Support::Totals.amount` instead of their own lookups.
|
|
202
|
+
- **Remote purchases didn't count toward the rolling cap or velocity
|
|
203
|
+
limit.** Both count the transaction log (`~/.portage/transactions.json`),
|
|
204
|
+
and only the own-store `Dispatcher` wrote to it, so `buy --yes` against a
|
|
205
|
+
remote native-UCP store never added to either. `buy` now records a remote
|
|
206
|
+
completion there under the merchant host, with its amount, currency and
|
|
207
|
+
token ref: reserved before the store is asked, settled `complete` only
|
|
208
|
+
when it comes back purchased, `failed` otherwise. The own-store loopback
|
|
209
|
+
path is still left to `Dispatcher`, so nothing is counted twice. If the
|
|
210
|
+
log can't be written after the purchase, the report says so in
|
|
211
|
+
`warnings` rather than losing the purchase to an exception.
|
|
212
|
+
- **A garbage `PORTAGE_MIN_CONFIDENCE` broke every `buy`**, even with no
|
|
213
|
+
decision backend selected, because the threshold was parsed up front
|
|
214
|
+
either way. The env var is now read, and validated, only once a backend
|
|
215
|
+
is selected. An explicit `--min-confidence` is still always validated.
|
|
216
|
+
- **`buy --json` could refuse a flag without any JSON.** An out-of-range
|
|
217
|
+
threshold went to stderr as a bare line, and a flag OptionParser couldn't
|
|
218
|
+
read (`--min-confidence high`, an unknown flag) escaped as a backtrace.
|
|
219
|
+
Under `--json` both now print a report with `outcome: "invalid_option"`
|
|
220
|
+
and exit 1; without it, both are one line on stderr. The threshold is
|
|
221
|
+
also checked before the search when `buy` has no URL, not after it.
|
|
222
|
+
|
|
7
223
|
## [0.6.4] - 2026-09-22
|
|
8
224
|
|
|
9
225
|
- **Every dead-end hand-off pointed the shopper at the store's refund
|
data/README.md
CHANGED
|
@@ -68,7 +68,9 @@ gem install portage-cli
|
|
|
68
68
|
|
|
69
69
|
```bash
|
|
70
70
|
portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
|
|
71
|
-
[--yes] [--dry-run] [--
|
|
71
|
+
[--yes] [--dry-run] [--decision-backend jev|laya]
|
|
72
|
+
[--min-confidence N] [--json]
|
|
73
|
+
[--wait [--wait-timeout DURATION|off]]
|
|
72
74
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
73
75
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
74
76
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
@@ -87,8 +89,20 @@ portage policy set [--per-transaction-cap N --currency CUR]
|
|
|
87
89
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
88
90
|
[--velocity-count N --velocity-window-seconds N]
|
|
89
91
|
[--allow HOST ...] [--clear-allowlist]
|
|
92
|
+
portage orders reconcile [--checkout ID] [--json]
|
|
90
93
|
```
|
|
91
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
|
+
|
|
92
106
|
- `--query` — search term. Against the store's catalog when you name a store,
|
|
93
107
|
against the search backends when you don't.
|
|
94
108
|
- `--qty` — quantity, default `1`.
|
|
@@ -101,15 +115,27 @@ portage policy set [--per-transaction-cap N --currency CUR]
|
|
|
101
115
|
search ranks first. If the id isn't in the results, nothing is bought.
|
|
102
116
|
- `--store` — name the merchant without giving a full URL; skips the search.
|
|
103
117
|
- `--max-price` — in major units (`400` means 400), compared per offer in that
|
|
104
|
-
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.
|
|
105
122
|
- `--limit` — how many candidate stores to probe, capped at 12.
|
|
106
123
|
- `--yes` — skip the confirmation prompt before completing checkout.
|
|
107
124
|
- `--dry-run` — resolve and price the order without completing checkout.
|
|
125
|
+
- `--decision-backend` — opt into the confidence gate (see "Decisions" below):
|
|
126
|
+
`jev` or `laya`. Defaults to `PORTAGE_DECISION_BACKEND`; unset means off.
|
|
127
|
+
- `--min-confidence` — the gate's threshold, `0.0`–`1.0`. Defaults to
|
|
128
|
+
`PORTAGE_MIN_CONFIDENCE`, then `0.8`. The flag is always checked; the env
|
|
129
|
+
var is read (and checked) only while a backend is selected, so a stale
|
|
130
|
+
value can't block a buy that doesn't use the gate.
|
|
108
131
|
- `--json` — machine-readable report instead of the human-readable summary.
|
|
109
132
|
|
|
110
133
|
Exits `0` when a checkout completed (or a dry-run/browse/search resolved
|
|
111
134
|
successfully), `1` otherwise — including the "no native manifest, no adapter
|
|
112
|
-
credentials" dead-end case, so it's scriptable in CI.
|
|
135
|
+
credentials" dead-end case, so it's scriptable in CI. A flag that can't be
|
|
136
|
+
used (an unreadable value, an out-of-range threshold) stops the buy before
|
|
137
|
+
anything runs: on stderr normally, or as a JSON report with `outcome:
|
|
138
|
+
"invalid_option"` under `--json`.
|
|
113
139
|
|
|
114
140
|
### Compare
|
|
115
141
|
|
|
@@ -162,10 +188,15 @@ abandoned carts on someone else's site; left out of scope for now.
|
|
|
162
188
|
|
|
163
189
|
### History
|
|
164
190
|
|
|
165
|
-
Every `
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
191
|
+
Every `portage buy` that creates a checkout is logged locally to
|
|
192
|
+
`~/.portage/history.json` as a purchase, whatever came of it. Each entry
|
|
193
|
+
carries the report's `outcome` (`purchased`, `dry_run`, `policy_blocked`,
|
|
194
|
+
...), what the checkout held (`items`), its `total`, and, when it wasn't
|
|
195
|
+
completed, the `checkout_url` to finish it. "What did I already buy" is
|
|
196
|
+
the entries whose `outcome` is `purchased`. A `buy` that never reached a
|
|
197
|
+
checkout (no match, browse-only, a dead end) is logged as a search at that
|
|
198
|
+
store instead, as is every `find`, `compare`, and the search behind a
|
|
199
|
+
`buy` with no URL. The most recent 200 entries of each are kept.
|
|
169
200
|
|
|
170
201
|
```bash
|
|
171
202
|
portage history # both lists, most recent last
|
|
@@ -255,6 +286,231 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
|
|
|
255
286
|
limits bound to one enrolled card) are set via `portage payment enroll
|
|
256
287
|
--scope-*` above, not here.
|
|
257
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
|
+
|
|
418
|
+
### Decisions
|
|
419
|
+
|
|
420
|
+
`portage buy` and `portage find` make their judgment calls
|
|
421
|
+
(`docs/plans/system-one-decision-layer.md`) through rules that live in
|
|
422
|
+
`portage-ucp` core: `Support::OfferRanking`, `Support::Escalation` and
|
|
423
|
+
`PolicyGuard`. `portage-ucp-decision` wraps the same modules as typed
|
|
424
|
+
verdicts, so ranking, escalation and the policy check answer the same way
|
|
425
|
+
whether or not it's installed. It's an optional plugin, not a dependency:
|
|
426
|
+
|
|
427
|
+
```bash
|
|
428
|
+
gem install portage-ucp-decision # only needed for the confidence gate
|
|
429
|
+
```
|
|
430
|
+
|
|
431
|
+
The confidence gate is the one feature that needs the gem, because the
|
|
432
|
+
model backends live there.
|
|
433
|
+
|
|
434
|
+
Every `portage buy` report carries an `outcome`, so a script or agent loop
|
|
435
|
+
can branch on data rather than on the message text. The text output leads
|
|
436
|
+
with the same value, as `[outcome]`.
|
|
437
|
+
|
|
438
|
+
| `outcome` | Meaning | `checkout_url`? |
|
|
439
|
+
| --- | --- | --- |
|
|
440
|
+
| `purchased` | Completed. | no |
|
|
441
|
+
| `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
|
|
442
|
+
| `dry_run` | Checkout created, `--dry-run` stopped it. | no |
|
|
443
|
+
| `requires_escalation` | The store wants the shopper to finish. | yes |
|
|
444
|
+
| `checkout_mismatch` | Checkout differs from the request (`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`). | yes |
|
|
445
|
+
| `no_payment_token` | No `--payment-token` and no default payment method. | yes |
|
|
446
|
+
| `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
|
|
447
|
+
| `low_confidence` | The confidence gate held it. | yes |
|
|
448
|
+
| `permission_denied` | The store doesn't let this agent complete checkout. | yes |
|
|
449
|
+
| `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
|
|
450
|
+
| `no_match` | Nothing in the store's results matched. | no |
|
|
451
|
+
| `browse_only` | The store has a catalog but no UCP checkout. | when an adapter offers a link |
|
|
452
|
+
| `agent_profile_missing`, `request_rejected`, `unsupported_wire_shape` | Native UCP setup problems; the message names the fix. | no |
|
|
453
|
+
| `adapter_error`, `adapter_misconfigured` | Your own-store adapter failed; the message quotes it. | no |
|
|
454
|
+
| `dead_end` | No UCP and no adapter for this store. | no |
|
|
455
|
+
| `invalid_option` | A flag was refused before the buy started (`--json` only); `message` says which. | no |
|
|
456
|
+
|
|
457
|
+
Checkout reports also carry `items` (what the checkout holds, as opposed to
|
|
458
|
+
`products`, the search results) and the verdicts under `decisions:`:
|
|
459
|
+
|
|
460
|
+
```json
|
|
461
|
+
"decisions": {
|
|
462
|
+
"escalation": { "escalate": false, "reason": null },
|
|
463
|
+
"policy": { "allowed": true, "reason": null },
|
|
464
|
+
"confidence": { "proceed": false, "reason": "below_threshold", "confidence": 0.41,
|
|
465
|
+
"threshold": 0.8, "backend": "jev", "error": null }
|
|
466
|
+
}
|
|
467
|
+
```
|
|
468
|
+
|
|
469
|
+
Every verdict has a `reason`: `null` when the gate let the purchase through,
|
|
470
|
+
otherwise a string naming why it stopped it.
|
|
471
|
+
|
|
472
|
+
- **escalation** — `Support::Escalation`. A `requires_escalation` checkout
|
|
473
|
+
always escalates (`reason: "requires_escalation"`). A checkout that doesn't
|
|
474
|
+
match the request escalates (`reason: "mismatch"`) only under
|
|
475
|
+
`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`; otherwise it's reported as `warnings`.
|
|
476
|
+
- **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
|
|
477
|
+
before any `--yes` completion. `reason` is the guard's own
|
|
478
|
+
(`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
|
|
479
|
+
`velocity_exceeded`, `merchant_not_allowlisted`, `token_scope_merchant`,
|
|
480
|
+
`token_scope_amount`, `currency_mismatch`, or `total_unknown`, below).
|
|
481
|
+
It applies to remote native-UCP stores too. Before this check, only the
|
|
482
|
+
own-store adapter flow's in-process `Dispatcher` enforced the policy.
|
|
483
|
+
Rolling caps and velocity limits count the completed purchases at that
|
|
484
|
+
merchant in `~/.portage/transactions.json`. `Dispatcher` records
|
|
485
|
+
own-store purchases there, and `portage buy` records remote ones: reserved
|
|
486
|
+
before the store is asked to complete, then settled as `complete` only
|
|
487
|
+
when the store answers that it's purchased.
|
|
488
|
+
- **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
|
|
489
|
+
`PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
|
|
490
|
+
completion it asks the backend whether the checkout matches the request
|
|
491
|
+
and is safe to complete unattended. It sends the query, merchant,
|
|
492
|
+
quantity, line items, totals and warnings, never the payment token. The
|
|
493
|
+
gate fails closed, so three things hold the purchase: a score below the
|
|
494
|
+
threshold (`reason: "below_threshold"`), a backend that can't answer
|
|
495
|
+
(`"backend_error"`), and naming a backend without `portage-ucp-decision`
|
|
496
|
+
installed (`"not_installed"`). For the last two, `error` says what went
|
|
497
|
+
wrong. `jev` needs `JEV_API_KEY`; `laya` needs `LAYA_BRIDGE_SCRIPT` (see
|
|
498
|
+
`portage-ucp-decision`'s README). `portage doctor` flags whichever of
|
|
499
|
+
these is missing for the selected backend.
|
|
500
|
+
- **policy** also denies a checkout with no `total` line as
|
|
501
|
+
`total_unknown` whenever a spend cap is set, rather than skipping the cap.
|
|
502
|
+
|
|
503
|
+
A blocked or held purchase hands the checkout off the same way an escalation
|
|
504
|
+
does (`checkout_url`, plus `--auto-open`/`--notify-webhook` if set). The
|
|
505
|
+
shopper can finish it themselves. The webhook body is JSON: `event`
|
|
506
|
+
(`checkout_handoff`), `reason` (the report's `outcome`), `message` (the
|
|
507
|
+
report's own sentence), `store`, `query`, `checkout_url`, `checkout_id`,
|
|
508
|
+
`source`, `totals` and `warnings`. Any 2xx counts as delivered; the POST
|
|
509
|
+
gives up after 5 seconds and reports `notify_error` instead.
|
|
510
|
+
|
|
511
|
+
`portage find`'s offer order comes from `Support::OfferRanking`: buyable
|
|
512
|
+
first, then cheapest, then unpriced.
|
|
513
|
+
|
|
258
514
|
### Shipping address (own-store checkouts only)
|
|
259
515
|
|
|
260
516
|
When buying against your own store (`portage buy`'s step 2 adapter-credentials
|