portage-cli 0.11.0 → 0.12.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
checksums.yaml CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: f4200c871324b9f35de00e58c32177ec087c9922b3adda2ae45aa1b4673478f1
4
- data.tar.gz: e719be3b212a547be84ff5fa1232b08303c96288b7acf60f0fd9c2f1982fa0ef
3
+ metadata.gz: 4e0a17085649f21644b4e373afc590ab69b92cfdbf23c4b83fbf7ed33b1cc1c8
4
+ data.tar.gz: 75e4435e369e70f529af29ce9891208615d2f0d458df616dbf11a032f8276ed3
5
5
  SHA512:
6
- metadata.gz: 3bb580b0b6ac745462ecc241b4e151fa16c2a8651ac32aa6d53c84aca0b910da6095182680b6a832b3431539904a697332279de9b9340fb491581821eccf6519
7
- data.tar.gz: 7a75983b229489dc8519caa7a176122b00c8f41f56922f4b373c551a9ab044dace860f7ecdd6ba93d63feddf84177391d3ae92f13a6280acd138e704590ea4f3
6
+ metadata.gz: d79a48ad6cf39bad956cafbb2e9f3317485af02a14fa3305306a4185839ce3add20326025e9eb4cfa0e0e1709ecbade149b5b2e9109627fb9b3b6112f11e91ed
7
+ data.tar.gz: 5bbf4e713f496fef30925cb4f59de1ad929e1a51b960c804f6bb60bbce39c8dd0856d3efcd5641f175a25bebfd7868358d63a827d0e6123a3d5b9763a7d27ead
data/CHANGELOG.md CHANGED
@@ -6,6 +6,83 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.12.0] - 2026-10-01
10
+
11
+ - **Release note: the confidence check wants `portage-ucp-decision` 0.1.2.** `portage-ucp-decision` stays an optional install, not a dependency, but 0.1.2 is the version that rejects a malformed backend answer (see its changelog); the README says so.
12
+
13
+ - **`find --store URL --query Q [--max-price N]` searches one store, live.** The index-search hint, the `buy` skill and the docs already told agents to re-check an index hit this way, but `find` had no `--store`. It now skips the search backends and retailer offer sources and probes and searches only that store's catalogue (read-only: never a cart or checkout), with offers, `offer_ref`, `search_id` and the history entry shaped like any find, so `pick` and `buy --offer` work on them. A hand-off-only host is never fetched and is reported as such; a store without UCP is reported as having no catalogue; a non-http(s) URL is a usage error. The index-search hint now reads `find --store URL --query ...`.
14
+
15
+ - **Security: a priced line nobody asked for is a checkout mismatch.** `buy` requests exactly one
16
+ line, but only checked that line, so a store that added an upsell, a "shipping protection"
17
+ add-on or a second copy of the item (or, on a WebMCP cart, whatever was already in the store's
18
+ cart) still went through. Any other line now stops a real run with `checkout_mismatch` ("Store
19
+ added ... to checkout, which wasn't requested."), and a dry run flags it with
20
+ `checkout_mismatch: true`. An extra line that costs nothing (its own total, or its unit price
21
+ times quantity, is 0) is allowed, so a free gift or $0 sample doesn't block a purchase; one
22
+ whose cost can't be read counts as priced.
23
+
24
+ - **Security: the confidence check sends an allowlisted summary, and compares against the
25
+ approved quote.** The state sent to the decision backend (`PORTAGE_DECISION_BACKEND`, e.g.
26
+ `jev`, TypeSafe's hosted API) used to be a slice of the raw checkout hash, so whatever a store
27
+ nested under `line_items` or `totals` went along with it. It is now built field by field by the
28
+ new `Portage::Cli::ConfidenceState`: the request (query, store host, quantity, picked item id
29
+ and title), on a `buy --quote` run the approved quote (store, product id, title, quantity,
30
+ total, currency), and the checkout's status, currency, per-line item id, title, unit price,
31
+ quantity, totals and whether it's the requested line, the totals (shipping, tax, fees included),
32
+ applied discounts' titles and amounts and the selected shipping option's title and price, plus
33
+ `warnings`. Never the payment token, the address, the buyer's name, phone or email, ids, links,
34
+ discount codes or environment values; store strings are cut to 200 characters. The question
35
+ now asks the model to say yes only when the checkout matches the request and the approved
36
+ quote. `Buy.new` takes `quote_store:` and `quote_title:`, which `buy --quote` passes from the
37
+ saved quote. The check stays additive: it only sees a checkout the mismatch check, the quote
38
+ cap and the spend policy let through, and can hold it but never let through one they stop.
39
+
40
+ - **Security: the non-preset WebMCP hand-off runs the confidence check too.** A page whose WebMCP
41
+ tools build a checkout (no preset, so `buy` ends in `express_stop` through `finish_checkout`)
42
+ applied the quote cap and the mismatch stop but not the opt-in confidence check. With a decision
43
+ backend enabled it now runs before the hand-off, after those two. A hold reports `low_confidence`
44
+ with the store's `/cart` page as `checkout_url`, as the preset flow's does, and nothing is handed
45
+ to the checkout (a `profile` target is not navigated there). A backend error holds the same way.
46
+ No backend enabled: unchanged.
47
+
48
+ - **Security: the WebMCP hand-off flow runs the quote cap and the confidence check.** Against a
49
+ page whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy`
50
+ now checks, before that tool runs and before autofill: a `--quote` run's cap (`quote_changed`,
51
+ never handed off; this flow never checked the quote before), the mismatch check, then, when a
52
+ decision backend is enabled, the confidence check. A hold reports `low_confidence` with the
53
+ store's `/cart` page as `checkout_url`; checkout isn't opened and nothing is autofilled. A
54
+ backend error holds the same way.
55
+
56
+ - **Security: a quote with no total refuses instead of buying uncapped.** `buy --quote` capped the
57
+ checkout at the quote's total only when the quote had one. A quote saved from a dry run with no
58
+ priced total (a WebMCP preset dry run never has one) set no cap at all, so its `--yes` run
59
+ bought at whatever the store asked. It now ends in `quote_changed`, saying the quote has no
60
+ total, and nothing is bought or handed off.
61
+
62
+ - **Security: a checkout mismatch always stops the purchase.** `buy` used to stop on a checkout
63
+ that didn't match the request (the item dropped, another quantity, another unit price) only under
64
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`. Without it the mismatch was a `warnings` entry and the
65
+ purchase went ahead, so a real `--yes` run could pay for the wrong checkout and report
66
+ `purchased` (reported by ClawHub's security audit of the `portage-buy` skill). Now every
67
+ mismatch on a run that isn't `--dry-run` escalates (`decisions.escalation.reason: "mismatch"`)
68
+ and ends in `checkout_mismatch` before the payment token, policy and completion are reached. The
69
+ quote it ran under is spent, so the person dry-runs again for a new one. There is no opt-out:
70
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored, whatever its value. A
71
+ `--dry-run` keeps its `dry_run` outcome and `warnings`, and now adds `checkout_mismatch: true`
72
+ and says in `message` that a real run would stop. The check also compares currency: a checkout
73
+ in another currency than the catalog's price is a mismatch. The quoted total and currency are
74
+ still `--quote`'s `quote_changed` check, unchanged.
75
+
76
+ - **Security: the WebMCP hand-off flow stops on a mismatched cart too.** Against a WebMCP page
77
+ whose preset opens checkout through its own tool (Shopify's `proceed_to_checkout`), `buy` read
78
+ the cart back and checked it, but only put a mismatch in `warnings`: it still called the hand-off
79
+ tool, sending the browser tab to the store's checkout, and ran autofill there when it was
80
+ approved. Now any mismatch stops the run before that tool, with outcome `checkout_mismatch` and
81
+ `decisions.escalation.reason: "mismatch"`. Nothing is autofilled, and `checkout_url` is the
82
+ store's `/cart` page, so the shopper can look at the cart that was built. A matching cart hands
83
+ off as `express_stop`, as before. The dry run of this flow builds no cart, so it can't check one
84
+ and never carries `checkout_mismatch: true`.
85
+
9
86
  ## [0.11.0] - 2026-10-01
10
87
 
11
88
  - **Category classification uses the whole taxonomy.** `known-stores/categories.yml` is now generated
data/README.md CHANGED
@@ -29,6 +29,20 @@ portage find --query "burton snowboard" --max-price 400
29
29
 
30
30
  `portage buy` with no URL runs that search and then buys the offer you pick.
31
31
 
32
+ Already know the store? `portage find --store URL --query "..."` skips the search
33
+ backends and offer sources and searches only that store's catalogue, live and
34
+ read-only (catalogue search only, never a cart or checkout). Use it to re-check
35
+ an index hit or an earlier offer before quoting its price or stock. The report
36
+ has the same shape as a normal find (`offer_ref`, `search_id`, a history entry),
37
+ so `pick` and `buy --offer` work on its offers. A hand-off-only host is never
38
+ fetched and is reported as such; a store that doesn't speak UCP is reported as
39
+ having no catalogue, with exit code 1 (as for any find with no offers). `--store`
40
+ must be an http(s) URL (a bare host is read as https).
41
+
42
+ ```bash
43
+ portage find --store https://shop.example --query "cold brew" --json
44
+ ```
45
+
32
46
  Already have the item and want to know where else it's sold? `portage compare`
33
47
  resolves a product you name by URL + product id, then runs the same
34
48
  find pipeline against its title and ranks the results by how confident the
@@ -131,6 +145,7 @@ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
145
  portage buy --quote QUOTE_ID --yes [--json] ...
132
146
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
133
147
  portage find --query "..." [--max-price N] [--limit N] [--json]
148
+ portage find --store URL --query "..." [--max-price N] [--json]
134
149
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
135
150
  [--max-price N] [--json]
136
151
  portage check <url> [--json]
@@ -811,6 +826,30 @@ checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
811
826
  product search: nothing is added to the store's cart, the tab isn't sent to
812
827
  checkout and nothing is autofilled. The `dry_run` report carries a `would:`
813
828
  key with the line item, the hand-off tool and whether autofill would run.
829
+ Because it builds no cart, it can't check one, so it never carries
830
+ `checkout_mismatch: true`.
831
+
832
+ A real run against that kind of page reads the cart back after adding to it
833
+ and checks it the same way `buy` checks any checkout (item, quantity, unit
834
+ price, currency, extra lines). Any mismatch stops the run there, with outcome
835
+ `checkout_mismatch` and `decisions.escalation.reason: "mismatch"`: the
836
+ hand-off tool isn't called, so the tab never goes to checkout, and nothing is
837
+ autofilled. The cart is already on the store, so `checkout_url` is the
838
+ store's `/cart` page, for the shopper to look at. Items already sitting in
839
+ the store's cart count as extra lines, so they stop the run too.
840
+
841
+ The same run then applies the other gates a checkout gets before anything
842
+ leaves the cart: a `--quote` run's cap (`quote_changed`), and, when a
843
+ decision backend is enabled, the confidence check (see "Decisions"). A hold
844
+ from the confidence check reports `low_confidence` with the `/cart` page as
845
+ `checkout_url`; again the tab isn't sent to checkout and nothing is
846
+ autofilled.
847
+
848
+ A page whose WebMCP tools build a checkout (no preset) gets the same confidence
849
+ check before its `express_stop` hand-off, after the quote cap and the mismatch
850
+ stop. A hold reports `low_confidence` with the `/cart` page as `checkout_url`
851
+ and no navigation to the checkout, so a `profile` hand-off target is never sent
852
+ there. With no decision backend enabled, nothing changes.
814
853
 
815
854
  Once the flow hands off to the store's own checkout page, the shopper can
816
855
  opt into having it pre-filled: `--autofill`, or
@@ -842,7 +881,9 @@ gem install portage-ucp-decision # only needed for the confidence gate
842
881
  ```
843
882
 
844
883
  The confidence gate is the one feature that needs the gem, because the
845
- model backends live there.
884
+ model backends live there. Use `portage-ucp-decision` 0.1.2 or newer: it
885
+ rejects a backend answer that isn't a probability between 0 and 1, where
886
+ 0.1.1 let an answer above 1 clear any threshold.
846
887
 
847
888
  Every `portage buy` report carries an `outcome`, so a script or agent loop
848
889
  can branch on data rather than on the message text. The text output leads
@@ -852,12 +893,12 @@ with the same value, as `[outcome]`.
852
893
  | --- | --- | --- |
853
894
  | `purchased` | Completed. | no |
854
895
  | `needs_confirmation` | Checkout ready; rerun with `--yes`. | no |
855
- | `dry_run` | Checkout created, `--dry-run` stopped it. | no |
896
+ | `dry_run` | Checkout created, `--dry-run` stopped it. `checkout_mismatch: true` when a real run would stop on a mismatch. | no |
856
897
  | `requires_escalation` | The store wants the shopper to finish. | yes |
857
- | `checkout_mismatch` | Checkout differs from the request (`PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`). | yes |
898
+ | `checkout_mismatch` | Checkout differs from the request (item, quantity, unit price, currency, or a priced line nobody asked for); stopped before payment. | yes |
858
899
  | `no_payment_token` | No `--payment-token` and no default payment method. | yes |
859
900
  | `policy_blocked` | Your spend policy denied it; `decisions.policy.reason` says why. | yes |
860
- | `low_confidence` | The confidence gate held it. | yes |
901
+ | `low_confidence` | The confidence gate held it (before a `--yes` completion, or before a WebMCP hand-off to checkout, preset or not). | yes |
861
902
  | `permission_denied` | The store doesn't let this agent complete checkout. | yes |
862
903
  | `handoff_only` | Tier C: Amazon or another hand-off-only host. `legal_notice` explains why; see "Hand-off targets and hand-off-only hosts". | yes (built, never fetched) |
863
904
  | `store_refused` | The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
@@ -884,9 +925,15 @@ Every verdict has a `reason`: `null` when the gate let the purchase through,
884
925
  otherwise a string naming why it stopped it.
885
926
 
886
927
  - **escalation** — `Support::Escalation`. A `requires_escalation` checkout
887
- always escalates (`reason: "requires_escalation"`). A checkout that doesn't
888
- match the request escalates (`reason: "mismatch"`) only under
889
- `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH`; otherwise it's reported as `warnings`.
928
+ always escalates (`reason: "requires_escalation"`). So does a checkout that
929
+ doesn't match the request (`reason: "mismatch"`), on every run but
930
+ `--dry-run`, where the mismatch is reported in `warnings` and flagged with
931
+ `checkout_mismatch: true` instead. Nothing turns this off:
932
+ `PORTAGE_ABORT_ON_CHECKOUT_MISMATCH` is deprecated and ignored. A mismatch
933
+ is the requested line missing, a different quantity, a different unit
934
+ price or currency than the store's own catalog, or any extra line the
935
+ request didn't include. An extra line that costs nothing (a free gift, a
936
+ $0 sample) is allowed; one whose cost can't be read counts as priced.
890
937
  - **policy** — `PolicyGuard`, run on your policy file (see "Policy" above)
891
938
  before any `--yes` completion. `reason` is the guard's own
892
939
  (`per_transaction_cap_exceeded`, `rolling_spend_cap_exceeded`,
@@ -901,16 +948,39 @@ otherwise a string naming why it stopped it.
901
948
  when the store answers that it's purchased.
902
949
  - **confidence** — `ConfidenceGate`, off unless `--decision-backend` or
903
950
  `PORTAGE_DECISION_BACKEND` names a backend. Right before a `--yes`
904
- completion it asks the backend whether the checkout matches the request
905
- and is safe to complete unattended. It sends the query, merchant,
906
- quantity, line items, totals and warnings, never the payment token. The
907
- gate fails closed, so three things hold the purchase: a score below the
908
- threshold (`reason: "below_threshold"`), a backend that can't answer
909
- (`"backend_error"`), and naming a backend without `portage-ucp-decision`
910
- installed (`"not_installed"`). For the last two, `error` says what went
911
- wrong. `jev` needs `JEV_API_KEY`; `laya` needs `LAYA_BRIDGE_SCRIPT` (see
912
- `portage-ucp-decision`'s README). `portage doctor` flags whichever of
913
- these is missing for the selected backend.
951
+ completion, and before a WebMCP preset flow sends the browser to the
952
+ store's checkout (and autofills it), it asks the backend whether the
953
+ checkout matches the request, and the approved quote on a `--quote` run,
954
+ and is safe to complete unattended. It is additive only: it runs after
955
+ the mismatch check, the quote cap and the spend policy, on a checkout all
956
+ of them let through, so it can hold a purchase but never let through one
957
+ they would stop. The gate fails closed, so three things hold the
958
+ purchase: a score below the threshold (`reason: "below_threshold"`), a
959
+ backend that can't answer or answers with something that isn't a
960
+ probability (`"backend_error"`), and naming a backend without
961
+ `portage-ucp-decision` installed (`"not_installed"`). For the last two,
962
+ `error` says what went wrong. `jev` needs `JEV_API_KEY`; `laya` needs
963
+ `LAYA_BRIDGE_SCRIPT` (see `portage-ucp-decision`'s README). `portage
964
+ doctor` flags whichever of these is missing for the selected backend.
965
+
966
+ `jev` is TypeSafe's hosted API, so what it's sent is kept to a minimal,
967
+ allowlisted summary (`Portage::Cli::ConfidenceState`); nothing else on
968
+ the checkout is read:
969
+
970
+ - `request`: the search query, the store's host, the quantity, and the
971
+ picked item's id and title.
972
+ - `approved_quote` (`--quote` runs only): the quote's store, product id,
973
+ title, quantity, total and currency.
974
+ - `checkout`: status, currency; per line, item id, title, unit price,
975
+ quantity, line totals and whether it's the requested line; the totals
976
+ (subtotal, shipping, tax, fees, total — whatever the store lists);
977
+ applied discounts' titles and amounts; the selected shipping option's
978
+ title and price.
979
+ - `warnings`.
980
+
981
+ Never sent: the payment token, the shipping address, the buyer's name,
982
+ phone or email, checkout ids, links and URLs, discount codes, or anything
983
+ from your environment. Store-supplied strings are cut to 200 characters.
914
984
  - **policy** also denies a checkout with no `total` line as
915
985
  `total_unknown` whenever a spend cap is set, rather than skipping the cap.
916
986
 
@@ -9,6 +9,7 @@ require_relative "payment_methods"
9
9
  require_relative "setting"
10
10
  require_relative "decisions"
11
11
  require_relative "confidence_check"
12
+ require_relative "confidence_state"
12
13
  require_relative "checkout_handoff"
13
14
  require_relative "money"
14
15
  require_relative "notifier"
@@ -122,14 +123,18 @@ module Portage
122
123
  # hand off the real checkout first checks its total against this
123
124
  # (with `quote_currency:`) and reports `quote_changed` instead if it
124
125
  # is higher, in another currency, or missing. See #finish_checkout.
125
- # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
126
+ # @param quote_store [String, nil] the approved quote's store, and
127
+ # @param quote_title [String, nil] its title — set by `buy --quote`
128
+ # alongside quote_total:. Only the confidence check reads them: they
129
+ # go into its `approved_quote` (see #confidence_quote).
130
+ # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize -- all keywords; one per flag, plus
126
131
  # injectable collaborators, each assigned to its own ivar
127
132
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
128
133
  auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
129
134
  transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
130
135
  webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
131
- quote_total: nil, quote_currency: nil)
132
- # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
136
+ quote_total: nil, quote_currency: nil, quote_store: nil, quote_title: nil)
137
+ # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength, Metrics/AbcSize
133
138
  raw = url.to_s.strip
134
139
  @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
135
140
  @query = query
@@ -151,6 +156,8 @@ module Portage
151
156
  @json = json
152
157
  @quote_total = quote_total
153
158
  @quote_currency = quote_currency
159
+ @quote_store = quote_store
160
+ @quote_title = quote_title
154
161
  @webmcp_bridge = webmcp_bridge
155
162
  @decisions = {}
156
163
  end
@@ -510,7 +517,9 @@ module Portage
510
517
  # call it against. Builds a cart, reads it back through the (already
511
518
  # cart/checkout-capable, per the gate in #webmcp_flow) session so
512
519
  # #reconcile_checkout has a `get_cart`-shaped document to check against
513
- # (there's no checkout document either), then calls the preset's
520
+ # (there's no checkout document either), stops there if the quote cap,
521
+ # the mismatch check or the opt-in confidence check holds it
522
+ # (#webmcp_cart_held_report), and only then calls the preset's
514
523
  # `handoff_checkout` tool directly on the bridge — after the cart
515
524
  # read-back, not before, so any post-mutation "page not ready" gap
516
525
  # (see Transport's own retry) has already been waited out by then.
@@ -536,24 +545,84 @@ module Portage
536
545
  end
537
546
  return webmcp_handoff_dry_run_report(products, product, preset) if @dry_run
538
547
 
539
- created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
540
- context: buyer_context, meta: agent_meta)
541
- cart = session.get_cart(cart_id: created["id"], meta: agent_meta)
542
- warnings = reconcile_checkout(product, cart)
548
+ @product = product
549
+ cart = webmcp_build_cart(session, product)
550
+ held = webmcp_cart_held_report(products, product, cart)
551
+ return held if held
543
552
 
544
553
  result = @webmcp_bridge.execute_tool(preset.handoff_checkout, {})
545
554
  autofill = attempt_webmcp_autofill(preset)
546
- webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), warnings,
555
+ webmcp_handoff_report("webmcp", products, cart.merge("continue_url" => url_from_handoff(result)), [],
547
556
  autofill: autofill)
548
557
  end
549
558
 
559
+ # The same gates #finish_checkout runs before anything leaves this
560
+ # process, in the same order, all before the hand-off tool sends the
561
+ # tab to checkout and before anything is autofilled: the quote cap,
562
+ # then the deterministic mismatch stop, then (only when a decision
563
+ # backend is enabled) the confidence check. The confidence check only
564
+ # sees a cart the first two let through, so it can hold this hand-off
565
+ # but never let through one they stop.
566
+ # @return [Hash, nil] the report for the first gate that held, or nil.
567
+ def webmcp_cart_held_report(products, product, cart)
568
+ warnings = reconcile_checkout(product, cart)
569
+ return quote_changed_report("webmcp", products, cart, warnings) if quote_exceeded?(cart)
570
+ return webmcp_cart_mismatch_report(products, cart, warnings) if warnings.any?
571
+
572
+ webmcp_low_confidence_report(products, cart)
573
+ end
574
+
575
+ # A hold here points at the store's cart page, like the mismatch stop:
576
+ # checkout was never opened, so there's nothing at a checkout URL yet.
577
+ def webmcp_low_confidence_report(products, cart)
578
+ verdict = decide_confidence(cart, [])
579
+ return nil if verdict.nil? || verdict[:proceed]
580
+
581
+ handoff_report("webmcp", products, cart.merge("continue_url" => webmcp_cart_page_url), [],
582
+ outcome: "low_confidence",
583
+ message: "#{low_confidence_message(verdict)} Checkout wasn't opened, and nothing was " \
584
+ "autofilled.")
585
+ end
586
+
587
+ # Adds the line to the store's cart, then reads the cart back.
588
+ def webmcp_build_cart(session, product)
589
+ created = session.create_cart(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
590
+ context: buyer_context, meta: agent_meta)
591
+ session.get_cart(cart_id: created["id"], meta: agent_meta)
592
+ end
593
+
594
+ # Same fail-closed rule as #full_buy (see #decide_escalation): a cart
595
+ # that doesn't match the request stops here, before the hand-off tool
596
+ # sends the tab to checkout and before anything is autofilled. This
597
+ # flow used to only add the mismatch to `warnings` and carry on to
598
+ # checkout. The cart already exists on the store, so the report points
599
+ # at the store's cart page for the person to inspect, not at checkout.
600
+ # Every preset with a `handoff_checkout` tool today is Shopify's, where
601
+ # that page is /cart. A cart has no checkout status, so the verdict is
602
+ # the mismatch alone: `decisions.escalation.reason` is "mismatch", as
603
+ # on #full_buy's own stop.
604
+ def webmcp_cart_mismatch_report(products, cart, warnings)
605
+ @decisions[:escalation] = Decisions.escalation(checkout_status: nil, warnings: warnings)
606
+ handoff_report("webmcp", products, cart.merge("continue_url" => webmcp_cart_page_url), warnings,
607
+ outcome: "checkout_mismatch",
608
+ message: "Stopped before checkout — the store's cart didn't match the request: " \
609
+ "#{warnings.join(' ')} Nothing was bought, and checkout wasn't opened.")
610
+ end
611
+
612
+ def webmcp_cart_page_url
613
+ URI.join(@uri, "/cart").to_s
614
+ end
615
+
550
616
  # Unlike #full_buy's dry run (which still creates a UCP checkout, since
551
617
  # that's a document the store drops on its own), a dry run here stops
552
618
  # before `create_cart`: every step after it changes something outside
553
619
  # this process — a real cart on the store, the bridge's own tab
554
620
  # navigated to checkout, and (with autofill on) typing into that page.
555
621
  # None of that is safe to do on a preview run, so this only reports
556
- # what the run would have done. No checkout_id either, so History
622
+ # what the run would have done. That also means there's no cart to
623
+ # reconcile, so unlike #dry_run_report this can never carry
624
+ # `checkout_mismatch: true`; the real run checks the cart and stops
625
+ # there instead. No checkout_id either, so History
557
626
  # records it as a search, not a purchase.
558
627
  def webmcp_handoff_dry_run_report(products, product, preset)
559
628
  build_report(
@@ -764,6 +833,7 @@ module Portage
764
833
  message: no_match_message)
765
834
  end
766
835
 
836
+ @product = product
767
837
  checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
768
838
  fulfillment: requested_fulfillment(fulfillment_adapter),
769
839
  context: buyer_context, meta: agent_meta)
@@ -779,34 +849,78 @@ module Portage
779
849
  # (if surprising) checkout response, not a transport error. Buying
780
850
  # blind against a mismatch this method could have caught defeats the
781
851
  # point of an agent shopping on the buyer's behalf, so this always
782
- # checks and surfaces what it finds; PORTAGE_ABORT_ON_CHECKOUT_MISMATCH
783
- # additionally refuses to proceed rather than merely warning.
852
+ # checks, and any warning it returns stops a real purchase before
853
+ # payment (see #decide_escalation). The quoted total and currency are
854
+ # #quote_exceeded?'s job, not this method's.
855
+ #
856
+ # This is the authoritative check, and it always runs first: the
857
+ # opt-in model check (#decide_confidence) only ever sees a checkout
858
+ # that already passed it, and can hold that checkout but never
859
+ # overrule a warning from here.
784
860
  def reconcile_checkout(product, checkout)
785
861
  item_id = line_item_id_of(product)
786
- line = Array(checkout["line_items"]).find { |li| li.dig("item", "id") == item_id }
862
+ lines = Array(checkout["line_items"])
863
+ line = lines.find { |li| li.dig("item", "id") == item_id }
787
864
  return ["Store dropped the requested item (#{item_id}) from checkout."] unless line
788
865
 
789
866
  warnings = []
790
867
  if line["quantity"] != @qty
791
868
  warnings << "Store checked out quantity #{line['quantity']}, not the requested #{@qty}."
792
869
  end
870
+ warnings + price_mismatches(product, item_id, line, checkout["currency"]) +
871
+ unrequested_lines(lines.reject { |li| li.equal?(line) })
872
+ end
873
+
874
+ # Only one line is ever requested, so any other line is something the
875
+ # person never asked for: an upsell, a "shipping protection" add-on,
876
+ # or (on a WebMCP cart) whatever was already sitting in the store's
877
+ # cart. One that costs nothing is let through, so a store's free gift
878
+ # or $0 sample doesn't stop the purchase; one whose cost can't be read
879
+ # is treated as costing something, so it stops.
880
+ def unrequested_lines(extras)
881
+ extras.reject { |li| line_cost(li)&.zero? }.map do |li|
882
+ label = [li.dig("item", "id"), li.dig("item", "title")].compact.join(" ")
883
+ "Store added #{label.empty? ? 'a line' : label} to checkout, which wasn't requested."
884
+ end
885
+ end
886
+
887
+ # The line's own total, else its unit price times its quantity; nil
888
+ # when the line carries neither.
889
+ def line_cost(line)
890
+ total = Portage::Ucp::Support::Totals.amount(line["totals"])
891
+ return total if total.is_a?(Numeric)
892
+
893
+ price = line.dig("item", "price")
894
+ quantity = line["quantity"] || 1
895
+ price * quantity if price.is_a?(Numeric) && quantity.is_a?(Numeric)
896
+ end
897
+
898
+ # A unit price is only comparable in the catalog's own currency, so a
899
+ # checkout in another one is reported as that, not as a price change.
900
+ def price_mismatches(product, item_id, line, currency)
901
+ catalog_currency = expected_unit_currency(product, item_id)
902
+ if catalog_currency && currency && catalog_currency != currency
903
+ return ["Store checked out in #{currency}, not the catalog's #{catalog_currency}."]
904
+ end
793
905
 
794
906
  expected = expected_unit_price(product, item_id)
795
907
  actual = line.dig("item", "price")
796
- if expected && actual && expected != actual
797
- warnings << "Store priced the item at #{actual} #{checkout['currency']} minor units per unit, " \
798
- "not the catalog's #{expected}."
799
- end
800
- warnings
908
+ return [] unless expected && actual && expected != actual
909
+
910
+ ["Store priced the item at #{actual} #{currency} minor units per unit, not the catalog's #{expected}."]
801
911
  end
802
912
 
803
913
  def expected_unit_price(product, item_id)
804
- variant = Array(product["variants"]).find { |v| v["id"] == item_id }
805
- variant&.dig("price", "amount") || product.dig("price_range", "min", "amount")
914
+ expected_price_of(product, item_id)&.dig("amount")
806
915
  end
807
916
 
808
- def abort_on_mismatch?
809
- Setting.flag?(env: "PORTAGE_ABORT_ON_CHECKOUT_MISMATCH")
917
+ def expected_unit_currency(product, item_id)
918
+ expected_price_of(product, item_id)&.dig("currency")
919
+ end
920
+
921
+ def expected_price_of(product, item_id)
922
+ variant = Array(product["variants"]).find { |v| v["id"] == item_id }
923
+ variant&.dig("price") || product.dig("price_range", "min")
810
924
  end
811
925
 
812
926
  # Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
@@ -963,22 +1077,38 @@ module Portage
963
1077
  escalation = decide_escalation(checkout, warnings)
964
1078
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
965
1079
  return dry_run_report(source, products, checkout, warnings) if @dry_run
966
- return webmcp_handoff_report(source, products, checkout, warnings) if force_handoff
1080
+ return webmcp_handoff_checkout_report(source, products, checkout, warnings) if force_handoff
967
1081
  return confirmation_needed_report(source, products, checkout, warnings) unless confirmed?
968
1082
 
969
1083
  complete(session, source, products, checkout, warnings)
970
1084
  end
971
1085
 
1086
+ # The non-preset WebMCP hand-off (`express_stop` through #full_buy). Same
1087
+ # last gate as #webmcp_handoff_checkout_flow's: with a decision backend
1088
+ # enabled, the confidence check runs before anything is handed off, on a
1089
+ # checkout the quote cap and the mismatch stop already let through. A
1090
+ # hold reports `low_confidence` pointing at the store's cart page, as the
1091
+ # preset flow's does, so nothing is sent to a `profile` target's checkout.
1092
+ # No backend named: straight to the hand-off, as before.
1093
+ def webmcp_handoff_checkout_report(source, products, checkout, warnings)
1094
+ webmcp_low_confidence_report(products, checkout) ||
1095
+ webmcp_handoff_report(source, products, checkout, warnings)
1096
+ end
1097
+
972
1098
  # First in #finish_checkout, ahead of the escalation gates: a
973
1099
  # `quote_changed` refusal never hands off, since that would spend the
974
1100
  # quote and open a checkout the person never approved. #complete is
975
1101
  # the only place this file charges, and it is reached only through
976
1102
  # #finish_checkout.
1103
+ #
1104
+ # A quote with no total (its dry run had none to show, as a WebMCP
1105
+ # preset dry run never does) caps nothing, so it refuses too, rather
1106
+ # than buying at whatever the store now asks.
977
1107
  def quote_exceeded?(checkout)
978
- return false unless @quote_total
1108
+ return false unless quote_run?
979
1109
 
980
1110
  total = checkout_total(checkout)
981
- total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
1111
+ @quote_total.nil? || total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
982
1112
  end
983
1113
 
984
1114
  def quote_changed_report(source, products, checkout, warnings)
@@ -990,6 +1120,11 @@ module Portage
990
1120
  end
991
1121
 
992
1122
  def quote_changed_message(total, currency)
1123
+ if @quote_total.nil?
1124
+ return "The quote has no total to hold this checkout (now #{quoted_amount(total, currency)}) to — " \
1125
+ "nothing was bought. Dry-run again for a priced quote."
1126
+ end
1127
+
993
1128
  "The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
994
1129
  "now #{quoted_amount(total, currency)}) — nothing was bought."
995
1130
  end
@@ -998,15 +1133,18 @@ module Portage
998
1133
 
999
1134
  # Hand off vs. keep going is Decisions.escalation's call
1000
1135
  # (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
1001
- # literal `requires_escalation` status always escalates. A mismatch
1002
- # from #reconcile_checkout escalates only under
1003
- # PORTAGE_ABORT_ON_CHECKOUT_MISMATCH. By default the warnings are
1004
- # surfaced on the report, and the purchase is not stopped for them.
1136
+ # literal `requires_escalation` status always escalates, and so does
1137
+ # any mismatch from #reconcile_checkout, with no setting to turn that
1138
+ # off. This used to only warn unless PORTAGE_ABORT_ON_CHECKOUT_MISMATCH
1139
+ # was set, so by default a checkout that didn't match what the person
1140
+ # approved was still paid for and reported `purchased`. That variable
1141
+ # is now ignored. A --dry-run never pays, so there the mismatch stays
1142
+ # in `warnings` and #dry_run_report flags it instead, since an agent
1143
+ # needs the priced checkout to tell the person what went wrong.
1005
1144
  # The verdict lands on the report's `decisions:`, and the report's
1006
1145
  # `outcome:` names which gate (if any) stopped the purchase.
1007
1146
  def decide_escalation(checkout, warnings)
1008
- verdict = Decisions.escalation(checkout_status: checkout["status"],
1009
- warnings: abort_on_mismatch? ? warnings : [])
1147
+ verdict = Decisions.escalation(checkout_status: checkout["status"], warnings: @dry_run ? [] : warnings)
1010
1148
  @decisions[:escalation] = verdict
1011
1149
  verdict
1012
1150
  end
@@ -1129,17 +1267,48 @@ module Portage
1129
1267
  Portage::Ucp::Support::Totals.amount(checkout["totals"])
1130
1268
  end
1131
1269
 
1132
- # Never includes the payment token: the state goes to a model backend,
1133
- # which may be a hosted API (Jev).
1270
+ # Runs only on a checkout that already passed #reconcile_checkout, the
1271
+ # quote cap and the spend policy, so it can hold a purchase but never
1272
+ # let through one those would stop. The state is ConfidenceState's
1273
+ # allowlisted summary — never the payment token, the address or the
1274
+ # buyer's contact details — because it goes to a model backend that
1275
+ # may be a hosted third-party API (Jev).
1134
1276
  def decide_confidence(checkout, warnings)
1135
- verdict = confidence_check.call(
1136
- query: @query, merchant: @uri.host, quantity: @qty, warnings: warnings,
1137
- checkout: checkout.slice("id", "status", "currency", "line_items", "totals")
1138
- )
1277
+ return nil unless confidence_check.enabled?
1278
+
1279
+ verdict = confidence_check.call(confidence_state(checkout, warnings))
1139
1280
  @decisions[:confidence] = verdict if verdict
1140
1281
  verdict
1141
1282
  end
1142
1283
 
1284
+ def confidence_state(checkout, warnings)
1285
+ item_id = @product && line_item_id_of(@product)
1286
+ ConfidenceState.build(
1287
+ request: { query: @query, merchant: @uri.host, quantity: @qty, item_id: item_id,
1288
+ item_title: picked_title(item_id) },
1289
+ checkout: checkout, warnings: warnings, quote: confidence_quote
1290
+ )
1291
+ end
1292
+
1293
+ # The picked product's title from the store's own search, plus its
1294
+ # variant's when the checked-out variant has one ("Tee — Large").
1295
+ def picked_title(item_id)
1296
+ return nil unless @product
1297
+
1298
+ [@product["title"], variant_matching(@product, item_id)&.dig("title")].compact.uniq.join(" — ")
1299
+ end
1300
+
1301
+ # What `buy --quote` pinned, as the person approved it. nil on a run
1302
+ # with no quote.
1303
+ def confidence_quote
1304
+ return nil unless quote_run?
1305
+
1306
+ { store: @quote_store, product_id: @product_id, title: @quote_title, quantity: @qty,
1307
+ total: @quote_total, currency: @quote_currency }
1308
+ end
1309
+
1310
+ def quote_run? = !(@quote_store || @quote_total || @quote_currency).nil?
1311
+
1143
1312
  def confidence_check
1144
1313
  @confidence_check ||= ConfidenceCheck.new
1145
1314
  end
@@ -1410,9 +1579,18 @@ module Portage
1410
1579
  @yes
1411
1580
  end
1412
1581
 
1582
+ # `warnings` here are #reconcile_checkout's mismatches, which a real
1583
+ # run of the same checkout stops on — so the report says so up front,
1584
+ # before anyone approves a quote for it.
1413
1585
  def dry_run_report(source, products, checkout, warnings = [])
1414
- checkout_report(source, products, checkout, outcome: "dry_run", warnings: warnings,
1415
- message: "Dry run — checkout created but not completed.")
1586
+ message = "Dry run — checkout created but not completed."
1587
+ extra = {}
1588
+ if warnings.any?
1589
+ message += " It doesn't match the request (#{warnings.join(' ')}), so a real purchase would stop " \
1590
+ "with checkout_mismatch."
1591
+ extra[:checkout_mismatch] = true
1592
+ end
1593
+ checkout_report(source, products, checkout, outcome: "dry_run", warnings: warnings, message: message, **extra)
1416
1594
  end
1417
1595
 
1418
1596
  def confirmation_needed_report(source, products, checkout, warnings = [])
@@ -8,7 +8,9 @@ module Portage
8
8
  # § Responsibilities 3) as `portage buy` uses it: one yes/no question put
9
9
  # to a Decision::ModelBackends backend right before an unattended
10
10
  # (`--yes`) completion — "is this checkout what the shopper asked for,
11
- # and safe to complete without a person looking at it?"
11
+ # and safe to complete without a person looking at it?" Also asked
12
+ # before the WebMCP preset flow sends the browser to the store's
13
+ # checkout and autofills it (Buy#webmcp_cart_held_report).
12
14
  #
13
15
  # Default off. Nothing asks a model anything unless a backend is named,
14
16
  # via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
@@ -22,6 +24,16 @@ module Portage
22
24
  # purchase and hands the checkout to the shopper. It never
23
25
  # auto-approves anything `--yes` wouldn't already have allowed.
24
26
  #
27
+ # Additive only. Buy runs it after #reconcile_checkout's deterministic
28
+ # mismatch check, the quote cap and the spend policy, and only on a
29
+ # checkout all three let through, so a "yes" here can never let
30
+ # through a purchase one of those would stop. A "no" can only hold it.
31
+ #
32
+ # What it sends is ConfidenceState's allowlisted summary, never the
33
+ # raw checkout: no payment token, no address, no buyer contact
34
+ # details. #call JSON-encodes whatever state it is given, so a caller
35
+ # outside Buy has to build its state the same way.
36
+ #
25
37
  # Fails closed. An unknown backend name, a backend that isn't configured
26
38
  # (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
27
39
  # the purchase, the same as a low score does. So does naming a backend
@@ -35,11 +47,18 @@ module Portage
35
47
  QUESTION = "safe_to_complete".freeze
36
48
  # Phrased so "yes" means "proceed": a noul answer's value is the
37
49
  # probability of yes, and that is what ConfidenceGate thresholds.
38
- INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
39
- "query, the merchant, the requested quantity, the checkout's line items and totals, and " \
40
- "any warnings about where the checkout differs from the request. Answer yes only if the " \
41
- "checkout clearly matches what the shopper asked for and is safe to complete without a " \
42
- "person reviewing it first.".freeze
50
+ INSTRUCTIONS = "The state is a summary of a checkout an agent built on a shopper's behalf. `request` " \
51
+ "is what the shopper asked for: the search query, the store, the quantity, and the item " \
52
+ "picked from the store's search results. `approved_quote`, when present, is the exact " \
53
+ "purchase the shopper approved beforehand: store, product, title, quantity, total and " \
54
+ "currency. `checkout` is what the store is about to charge for: its line items (`requested` " \
55
+ "marks the line that was asked for), totals including any shipping, tax and fees, " \
56
+ "discounts and the selected shipping option. `warnings` lists differences an automatic " \
57
+ "check already found. Answer yes only if the checkout clearly matches the request and, " \
58
+ "when there is one, the approved quote: the same product (not a different model, size, " \
59
+ "bundle or subscription), the same quantity, no extra items, and a total with no " \
60
+ "surprising shipping, fees or other charges. Answer no if anything is unclear, missing " \
61
+ "or doesn't fit, or if the checkout would need a person to review it before paying.".freeze
43
62
 
44
63
  # @param backend [String, nil] a ModelBackends::REGISTRY key; nil
45
64
  # defers to PORTAGE_DECISION_BACKEND.
@@ -64,7 +83,8 @@ module Portage
64
83
 
65
84
  def enabled? = !@backend_name.nil?
66
85
 
67
- # @param state [Hash] JSON-serializable. Never pass it a payment token.
86
+ # @param state [Hash] JSON-serializable — ConfidenceState.build's
87
+ # output. Never a payment token, address or contact detail.
68
88
  # @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
69
89
  # `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
70
90
  # `reason` is nil when it proceeds, else why it held:
@@ -0,0 +1,109 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # The state ConfidenceCheck sends to its model backend, which may be a
6
+ # hosted third-party API (Jev, run by TypeSafe). Built from an allowlist:
7
+ # every field below is copied by name, and nothing else in the checkout
8
+ # hash is ever read. A store can put a buyer block, a shipping address,
9
+ # payment handlers or anything else on its checkout, and none of it
10
+ # reaches the backend, because this never copies a sub-hash wholesale.
11
+ #
12
+ # Never sent: the payment token (or any token reference), the shipping
13
+ # address and destinations, the buyer's name, phone or email, links and
14
+ # continue URLs, ids of the checkout itself, image URLs, discount codes,
15
+ # and anything read from the environment.
16
+ #
17
+ # Sent:
18
+ # - `request`: the search query, the store's host, the requested
19
+ # quantity, and the item id and title that were picked from the
20
+ # store's own search results.
21
+ # - `approved_quote` (only on `buy --quote`): the store, product id,
22
+ # title, quantity, total and currency the person approved.
23
+ # - `checkout`: its status and currency; each line's item id, title,
24
+ # unit price, quantity, line totals and whether it is the requested
25
+ # line; the checkout's totals (subtotal, shipping, tax, fees, total:
26
+ # whatever types the store sends); applied discounts' titles and
27
+ # amounts; and the title and price of each selected shipping option.
28
+ # - `warnings`: the deterministic check's warnings, if any.
29
+ #
30
+ # Strings are cut to MAX_STRING characters and anything that isn't a
31
+ # string, number, boolean or nil is dropped, so a store can't smuggle a
32
+ # nested object (or a very long prompt) in through a title.
33
+ module ConfidenceState
34
+ MAX_STRING = 200
35
+
36
+ module_function
37
+
38
+ # @param request [Hash] `query:`, `merchant:`, `quantity:`,
39
+ # `item_id:`, `item_title:`.
40
+ # @param checkout [Hash] a checkout or cart wire hash, string-keyed.
41
+ # @param warnings [Array<String>]
42
+ # @param quote [Hash, nil] `store:`, `product_id:`, `title:`,
43
+ # `quantity:`, `total:`, `currency:` — nil when the run has no
44
+ # approved quote.
45
+ # @return [Hash] JSON-serializable, string-keyed.
46
+ def build(request:, checkout:, warnings:, quote: nil)
47
+ state = { "request" => pick(request, %i[query merchant quantity item_id item_title]) }
48
+ state["approved_quote"] = pick(quote, %i[store product_id title quantity total currency]) if quote
49
+ state["checkout"] = checkout_summary(checkout, request[:item_id])
50
+ state["warnings"] = Array(warnings).map { |warning| scalar(warning.to_s) }
51
+ state
52
+ end
53
+
54
+ def checkout_summary(checkout, requested_id)
55
+ { "status" => scalar(checkout["status"]), "currency" => scalar(checkout["currency"]),
56
+ "line_items" => Array(checkout["line_items"]).map { |line| line_summary(line, requested_id) },
57
+ "totals" => totals_summary(checkout["totals"]),
58
+ "discounts" => discounts_summary(checkout["discounts"]),
59
+ "shipping" => shipping_summary(checkout["fulfillment"]) }
60
+ end
61
+
62
+ def line_summary(line, requested_id)
63
+ line = {} unless line.is_a?(Hash)
64
+ item = line["item"].is_a?(Hash) ? line["item"] : {}
65
+ { "item_id" => scalar(item["id"]), "title" => scalar(item["title"]), "unit_price" => scalar(item["price"]),
66
+ "quantity" => scalar(line["quantity"]), "totals" => totals_summary(line["totals"]),
67
+ "requested" => !requested_id.nil? && item["id"] == requested_id }
68
+ end
69
+
70
+ def totals_summary(totals)
71
+ hashes(totals).map { |total| { "type" => scalar(total["type"]), "amount" => scalar(total["amount"]) } }
72
+ end
73
+
74
+ def discounts_summary(discounts)
75
+ applied = discounts.is_a?(Hash) ? discounts["applied"] : nil
76
+ hashes(applied).map do |discount|
77
+ { "title" => scalar(discount["title"]), "amount" => scalar(discount["amount"]) }
78
+ end
79
+ end
80
+
81
+ # Only the options the checkout has selected — the price the person
82
+ # pays for shipping — never the destinations they're priced for.
83
+ def shipping_summary(fulfillment)
84
+ methods = fulfillment.is_a?(Hash) ? fulfillment["methods"] : nil
85
+ hashes(methods).flat_map { |method| hashes(method["groups"]) }.filter_map do |group|
86
+ option = hashes(group["options"]).find { |o| o["id"] == group["selected_option_id"] }
87
+ next unless option
88
+
89
+ { "title" => scalar(option["title"]),
90
+ "amount" => scalar(Portage::Ucp::Support::Totals.amount(hashes(option["totals"]))) }
91
+ end
92
+ end
93
+
94
+ def pick(hash, keys)
95
+ keys.to_h { |key| [key.to_s, scalar(hash[key])] }
96
+ end
97
+
98
+ def hashes(value) = Array(value).grep(Hash)
99
+
100
+ def scalar(value)
101
+ case value
102
+ when String then value[0, MAX_STRING]
103
+ when Integer, true, false, nil then value
104
+ when Float then value.finite? ? value : nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -24,6 +24,12 @@ module Portage
24
24
  # search ranker and the purchase decision to `--yes` in one breath is how
25
25
  # you end up owning a counterfeit from a shop you've never heard of, so
26
26
  # picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
27
+ #
28
+ # `store:` narrows the same pipeline to one store the caller already
29
+ # named — the live re-check of an index hit or an earlier offer. No
30
+ # search backend or OfferSource runs; the store is probed and its catalog
31
+ # searched, read-only, so its offers carry the same shape, `offer_ref`
32
+ # and history entry as any other find.
27
33
  class Find
28
34
  CART_CAP = "dev.ucp.shopping.cart".freeze
29
35
  CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
@@ -43,8 +49,11 @@ module Portage
43
49
  # candidate on it is never probed (see #call): it still surfaces as
44
50
  # a candidate, marked `handoff_only: true`, so an agent can list it
45
51
  # ("Amazon also sells this") without this process ever fetching it.
52
+ # @param store [String, nil] a store URL (see Find.store_origin): search
53
+ # only that store, live, and skip every backend and OfferSource.
46
54
  def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
47
- offer_sources: nil, handoff_only: nil)
55
+ offer_sources: nil, handoff_only: nil, store: nil)
56
+ @store = store
48
57
  @query = query.to_s
49
58
  @limit = [limit, MAX_PROBES].min
50
59
  @max_price = max_price
@@ -55,6 +64,27 @@ module Portage
55
64
  @handoff_only = handoff_only || HandoffOnly.new
56
65
  end
57
66
 
67
+ # The origin a `--store` value names: an http(s) URL (or a bare host,
68
+ # as `check` and `buy` accept) collapsed onto scheme://host[:port].
69
+ # @return [String, nil] nil for anything else, such as ftp:// or junk.
70
+ def self.store_origin(url)
71
+ text = url.to_s.strip
72
+ return nil if text.empty? || (text.include?("://") && !text.match?(%r{\Ahttps?://}i))
73
+
74
+ uri = URI.parse(text.include?("://") ? text : "https://#{text}")
75
+ origin_of_uri(uri)
76
+ rescue URI::InvalidURIError
77
+ nil
78
+ end
79
+
80
+ def self.origin_of_uri(uri)
81
+ return nil unless uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
82
+
83
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
84
+ "#{uri.scheme}://#{uri.host.downcase}#{port}"
85
+ end
86
+ private_class_method :origin_of_uri
87
+
58
88
  def call
59
89
  return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
60
90
 
@@ -88,6 +118,8 @@ module Portage
88
118
  # --- Step 1: ask the backends who might sell this ---
89
119
 
90
120
  def candidate_origins
121
+ return store_candidate if @store
122
+
91
123
  seen = {}
92
124
  @backends.each do |backend|
93
125
  urls_from(backend).each { |url| add_candidate(seen, backend, url) }
@@ -95,6 +127,15 @@ module Portage
95
127
  seen.values.first(@limit)
96
128
  end
97
129
 
130
+ # `--store`: the one candidate is the named store itself. A hand-off-only
131
+ # host stays a candidate, flagged, so #probe_candidates never fetches it.
132
+ def store_candidate
133
+ origin = self.class.store_origin(@store)
134
+ return [] unless origin
135
+
136
+ [{ origin: origin, source: "store", handoff_only: @handoff_only.host?(URI.parse(origin).host) }]
137
+ end
138
+
98
139
  # Keyed by host rather than by full origin: backends routinely hand back
99
140
  # both `http://` and `https://` for the same shop, and probing one host
100
141
  # twice over two schemes is a wasted request every time. https wins when
@@ -141,6 +182,8 @@ module Portage
141
182
  # #urls_from gives the URL backends. --max-price applies here exactly
142
183
  # as it does to a probed store's offers in #offer.
143
184
  def source_offers
185
+ return [] if @store
186
+
144
187
  offers = @offer_sources.flat_map do |source|
145
188
  source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
146
189
  end
@@ -281,6 +324,8 @@ module Portage
281
324
  end
282
325
 
283
326
  def no_candidates_message
327
+ return "#{@store.inspect} isn't an http(s) store URL." if @store
328
+
284
329
  names = @backends.map(&:name)
285
330
  return no_backends_message if names.empty?
286
331
 
@@ -312,6 +357,8 @@ module Portage
312
357
  end
313
358
 
314
359
  def summary(candidates, stores, offers)
360
+ return store_summary(candidates.first, stores, offers) if @store
361
+
315
362
  # Counted from the offers, not `stores`: an OfferSource's offers
316
363
  # come from stores that were never probed.
317
364
  selling = offers.map { |o| o[:store] }.uniq.length
@@ -320,6 +367,22 @@ module Portage
320
367
 
321
368
  "Checked #{candidates.length} store(s); none of them speak UCP."
322
369
  end
370
+
371
+ # The `--store` wording: say what happened to that one store, not "N
372
+ # stores".
373
+ def store_summary(candidate, stores, offers)
374
+ origin = candidate[:origin]
375
+ return "Found #{offers.length} offer(s) at #{origin}." if offers.any?
376
+ return handoff_only_message(origin) if candidate[:handoff_only]
377
+ return "#{origin} doesn't speak UCP, so there is no catalogue to search." if stores.empty?
378
+
379
+ "#{origin} speaks UCP but has nothing matching \"#{@query}\"."
380
+ end
381
+
382
+ def handoff_only_message(origin)
383
+ "#{origin} is a hand-off-only retailer: Portage never fetches it, so it wasn't searched. " \
384
+ "Open the site yourself."
385
+ end
323
386
  end
324
387
  end
325
388
  end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.11.0".freeze
3
+ VERSION = "0.12.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -55,6 +55,7 @@ module Portage
55
55
  portage buy --quote QUOTE_ID --yes [--json] ...
56
56
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
57
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
58
+ portage find --store URL --query "..." [--max-price N] [--json] (one store, live, read-only)
58
59
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
59
60
  [--max-price N] [--json]
60
61
  portage check <url> [--json]
@@ -188,14 +189,29 @@ module Portage
188
189
  return nil
189
190
  end
190
191
 
191
- opts
192
+ valid_find_store?(opts) ? opts : nil
192
193
  end
193
194
  private_class_method :parse_find_options
194
195
 
196
+ # `--store` must be an http(s) URL (a bare host is read as https); anything
197
+ # else is a usage error rather than a silent no-op search.
198
+ def self.valid_find_store?(opts)
199
+ return true unless opts.key?(:store)
200
+ return true if Find.store_origin(opts[:store])
201
+
202
+ warn "portage: --store needs an http(s) store URL, got #{opts[:store].inspect}."
203
+ warn USAGE
204
+ false
205
+ end
206
+ private_class_method :valid_find_store?
207
+
195
208
  def self.find_option_parser(opts)
196
209
  opts[:proxy] = {}
197
210
  OptionParser.new do |parser|
198
211
  parser.on("--query QUERY") { |v| opts[:query] = v }
212
+ parser.on("--store URL", "Search only this store's catalogue, live and read-only (http/https)") do |v|
213
+ opts[:store] = v
214
+ end
199
215
  parser.on("--limit N", Integer) { |v| opts[:limit] = v }
200
216
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
201
217
  parser.on("--json") { opts[:json] = true }
@@ -414,7 +430,8 @@ module Portage
414
430
  parsed[:quote_record] = quote
415
431
  buy = parsed[:buy]
416
432
  buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
417
- buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
433
+ buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"], quote_store: quote["store"],
434
+ quote_title: quote["title"])
418
435
  execute_buy(parsed, quote["store"])
419
436
  end
420
437
  private_class_method :buy_from_quote
@@ -1512,7 +1529,8 @@ module Portage
1512
1529
  return "No index products match \"#{result[:query]}\"." if result[:products].empty?
1513
1530
 
1514
1531
  lines = result[:products].map { |p| index_search_line(p) }
1515
- lines << "(From the local index, not live: check the price and stock with `portage find --store`.)"
1532
+ lines << "(From the local index, not live: check the price and stock with " \
1533
+ "`portage find --store URL --query ...`.)"
1516
1534
  lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
1517
1535
  lines.join("\n")
1518
1536
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.11.0
4
+ version: 0.12.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-30 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: irb
@@ -208,6 +208,7 @@ files:
208
208
  - lib/portage/cli/classifier/table.rb
209
209
  - lib/portage/cli/compare.rb
210
210
  - lib/portage/cli/confidence_check.rb
211
+ - lib/portage/cli/confidence_state.rb
211
212
  - lib/portage/cli/config.rb
212
213
  - lib/portage/cli/console.rb
213
214
  - lib/portage/cli/decisions.rb