portage-cli 0.1.0 → 0.3.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: a4a32f5894d104c340e147d698772d21492c3172d6b8e102c4508e431d210a9e
4
- data.tar.gz: dbf75ae4f0c5bd7996095d40c0a7e019d4cf9e0376b529356e227d5879363a4a
3
+ metadata.gz: a4a3c3b0ce94430985a4660b316c7fafb711d8814bc9497bba70d866bf927dc1
4
+ data.tar.gz: ac8bbf8f8f08ca0eda4962adfa8d240dae05e54b571a40b8aed68a8b3dd3ea03
5
5
  SHA512:
6
- metadata.gz: e41a7814ccdf875fae65a3ec9001c678e6cd0f951c504a0f675d3a34c3126349486df39919e514056b2529305d6560745ca4e028269c69b58e60d6610465f3fc
7
- data.tar.gz: 75503266fb054b39739bcd2786083454e0afdefae6e64aaa2573ebc6b4633fd4c3956b16c61707cdbb3bc688a56e31734c4b04293678473b8ffc776a80ceb4d5
6
+ metadata.gz: 7484fb5d07dc6c4c9bcfbfb01f84909b6c0c551d3e117bb4854ca5e67049779657272ea3c5c904847d2381780b11ffd839efa0e689c9eb20d2db8bd903126737
7
+ data.tar.gz: 70e0a2624e6e20714c2898069e53440cb620044c513a2644f12576b3da96bd0b2be5cc6603a2774a4f229eb8e19d5724889b9353ef3fab628ec6c06587a03a00
data/CHANGELOG.md CHANGED
@@ -4,7 +4,43 @@ 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.1.0] - Unreleased
7
+ ## [Unreleased]
8
+
9
+ ## [0.3.0] - 2026-08-28
10
+
11
+ - Fixed: `find` and `buy` were treating `search_catalog`'s wire envelope
12
+ (`{"ucp" => ..., "products" => [...]}`) as the product list itself —
13
+ `Array(session.search_catalog(...))` wrapped the whole envelope Hash into a
14
+ single-element array instead of unwrapping `"products"`, so every offer
15
+ built from it was malformed against any real store. `Portage::Cli::CatalogProducts.from`
16
+ now unwraps the envelope (and the own-store adapter's raw
17
+ `CatalogSearchResult`) before either command touches the result.
18
+ - `portage compare <url> --product-id ID` (`Portage::Cli::Compare`, §22's
19
+ "find this same item elsewhere" mode) — resolves a named product, then runs
20
+ `find`'s own candidate-discovery/probe/rank pipeline against its title.
21
+ Every offer carries a `match:` tier (`confirmed`/`likely`/`unconfirmed`)
22
+ based on shared barcode/sku/`--id` identity, the origin store is excluded
23
+ by host, and `--results` truncates after ranking. Catalog-price only — no
24
+ `create_checkout` against candidate stores. Recorded to `portage history`
25
+ as a search.
26
+ - `portage history` — local purchase/search history (`Portage::Cli::History`),
27
+ logged automatically to `~/.portage/history.json` on every `find`/checkout-
28
+ reaching `buy`. `list` (`--purchases`/`--searches`, `--limit`, `--json`) and
29
+ `clear` (same scoping flags) subcommands. Separate from `ProbeCache`, which
30
+ remembers hosts, not actions.
31
+
32
+ ## [0.2.0] - 2026-08-21
33
+
34
+ - `PORTAGE_SHIP_*` env vars (`Portage::Cli::ShippingProfile`) — configure a
35
+ default shipping address for `portage buy`'s own-store adapter-loopback
36
+ path, the same way adapter credentials already live in env. `portage buy`
37
+ auto-picks the cheapest priced option per fulfillment group once an address
38
+ is submitted; there's no interactive rate picker, since the CLI drives one
39
+ automated purchase rather than a conversation. The native UCP session path
40
+ (a third-party store over stdio/HTTP) isn't wired yet — no real UCP server
41
+ to verify a `fulfillment` wire shape against.
42
+
43
+ ## [0.1.0] - 2026-08-14
8
44
 
9
45
  - Initial pre-release. `portage buy <url>` — native UCP discovery first,
10
46
  adapter fallback only when this process already has that platform's own
data/README.md CHANGED
@@ -29,6 +29,15 @@ 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 have the item and want to know where else it's sold? `portage compare`
33
+ resolves a product you name by URL + product id, then runs the same
34
+ find pipeline against its title and ranks the results by how confident the
35
+ match is:
36
+
37
+ ```bash
38
+ portage compare https://your-shop.example --product-id prod_123 --results 5
39
+ ```
40
+
32
41
  Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
33
42
  (for platform detection via `Resolver`) and
34
43
  [`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
@@ -60,6 +69,10 @@ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id
60
69
  [--yes] [--dry-run] [--json]
61
70
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
62
71
  portage find --query "..." [--max-price N] [--limit N] [--json]
72
+ portage compare <url> --product-id ID [--id VALUE ...] [--results N]
73
+ [--max-price N] [--json]
74
+ portage history [list] [--purchases|--searches] [--limit N] [--json]
75
+ portage history clear [--purchases|--searches]
63
76
  ```
64
77
 
65
78
  - `--query` — search term. Against the store's catalog when you name a store,
@@ -82,6 +95,98 @@ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
82
95
  successfully), `1` otherwise — including the "no native manifest, no adapter
83
96
  credentials" dead-end case, so it's scriptable in CI.
84
97
 
98
+ ### Compare
99
+
100
+ `portage compare <url> --product-id ID` finds other stores selling the same
101
+ item you already have. It resolves the named product, then runs `find`'s own
102
+ candidate-discovery/probe/rank pipeline against the product's title, scoring
103
+ each surviving offer instead of treating them all as equally confident hits:
104
+
105
+ - `--product-id` — required. The item to compare, at the store you name.
106
+ - `--id VALUE` — repeatable. A SKU, barcode (UPC/EAN/GTIN), or MPN you already
107
+ know, matched case-insensitively against every candidate's own identity
108
+ values. There's no way to tell the matcher which *kind* of identifier you
109
+ passed — the wire format doesn't distinguish them — so it doesn't pretend
110
+ to; passing one just adds it to the matching corpus. When omitted, the
111
+ origin product's own first variant sku/barcodes are used instead.
112
+ - `--results N` — how many ranked offers to return, default 5. Applies after
113
+ ranking, not before — a truncated result is always the *worst* N dropped,
114
+ never an arbitrary N. The underlying probe cap (candidate origins checked,
115
+ not results returned) stays `find`'s own limit and isn't exposed on this
116
+ subcommand.
117
+ - `--max-price` — same semantics as `find`'s.
118
+
119
+ Every offer carries a `match:` tier so a caller never mistakes a coincidence
120
+ for a confirmed match:
121
+
122
+ | Tier | Means |
123
+ | --- | --- |
124
+ | `confirmed` | Origin and candidate share a barcode value (UPC/EAN/GTIN) — the one identifier the spec treats as globally unique. |
125
+ | `likely` | Origin and candidate share a SKU, or an explicit `--id` hit landed on the candidate — both are "some string matched", not "a global identifier matched", so they share one tier rather than a false precision gradient. |
126
+ | `unconfirmed` | Same search query, nothing shared. Could be the same item; could just have a similar title. |
127
+
128
+ The origin store itself is excluded from results, matched by host (not by
129
+ raw origin string), so an `http://`/`https://`/trailing-slash variant of your
130
+ own store's URL doesn't show up as a "competitor." A `www.` variant is
131
+ treated as a different host, same as `find`'s own candidate dedupe — worth
132
+ knowing if your store answers on both.
133
+
134
+ **Known limitation: recall, not ranking, is the ceiling.** Compare searches
135
+ the backends using the origin product's own title — a store-specific
136
+ marketing string. If a backend never surfaces the competitor for that title,
137
+ no amount of tiering helps; every offer that *does* come back may be
138
+ `unconfirmed` because nothing more specific was searched. There's no
139
+ barcode/SKU-keyed second search pass yet.
140
+
141
+ **Catalog-price only.** No `create_checkout` step runs against any candidate
142
+ store — ranking uses each store's listed price, never a landed price
143
+ (shipping/tax included). Verifying the actual cheapest landed price would
144
+ mean starting a checkout on a store the shopper hasn't chosen, which risks
145
+ abandoned carts on someone else's site; left out of scope for now.
146
+
147
+ ### History
148
+
149
+ Every `find` (and `buy`, once it reaches a search) and every `buy` that
150
+ reaches checkout is logged locally to `~/.portage/history.json` — most recent
151
+ 200 entries each, purchases and searches kept separately. Browse-only `buy`
152
+ reports (no checkout reached) aren't logged as purchases.
153
+
154
+ ```bash
155
+ portage history # both lists, most recent last
156
+ portage history list --purchases # just purchases
157
+ portage history list --searches --limit 20
158
+ portage history clear # wipe both
159
+ portage history clear --purchases # wipe just one
160
+ ```
161
+
162
+ This is a local convenience cache, not an audit log — `portage history clear`
163
+ deletes it outright, and there's no server-side record.
164
+
165
+ ### Shipping address (own-store checkouts only)
166
+
167
+ When buying against your own store (`portage buy`'s step 2 adapter-credentials
168
+ fallback, described at the top of this file) and that adapter supports
169
+ `dev.ucp.shopping.fulfillment`, set a default shipping address via env
170
+ rather than a flag, same posture as adapter credentials:
171
+
172
+ ```bash
173
+ export PORTAGE_SHIP_STREET="1 Main St"
174
+ export PORTAGE_SHIP_CITY="Erie"
175
+ export PORTAGE_SHIP_REGION="PA" # optional
176
+ export PORTAGE_SHIP_COUNTRY="US"
177
+ export PORTAGE_SHIP_POSTAL_CODE="16501"
178
+ export PORTAGE_SHIP_FIRST_NAME="Ada" # optional
179
+ export PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
180
+ export PORTAGE_SHIP_PHONE="+1..." # optional
181
+ ```
182
+
183
+ `street`/`city`/`country`/`postal_code` are required — a partial profile is
184
+ treated as no profile at all. Once the merchant prices shipping options
185
+ against that address, `portage buy` auto-picks the cheapest per fulfillment
186
+ group; there's no interactive rate picker, since this drives one automated
187
+ purchase. Native (non-adapter) UCP stores don't get this yet — see
188
+ `portage-ucp`'s design log for why.
189
+
85
190
  ## Buying without a URL
86
191
 
87
192
  `portage find` and URL-less `portage buy` share one pipeline:
@@ -113,7 +113,7 @@ module Portage
113
113
 
114
114
  adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
115
115
  if adapter_supports_checkout?(adapter)
116
- full_buy(client_for(adapter), source: "adapter:#{platform.name}")
116
+ full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
117
117
  else
118
118
  catalog_only_adapter(adapter, platform)
119
119
  end
@@ -131,7 +131,7 @@ module Portage
131
131
  end
132
132
 
133
133
  def catalog_only_adapter(adapter, platform)
134
- products = Array(adapter.search_catalog(query: @query, limit: 10))
134
+ products = CatalogProducts.from(adapter.search_catalog(query: @query, limit: 10))
135
135
  checkout = redirect_checkout(adapter, products)
136
136
  build_report(
137
137
  source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
@@ -152,7 +152,13 @@ module Portage
152
152
 
153
153
  # --- The actual buy, shared by native and adapter-loopback sources ---
154
154
 
155
- def full_buy(session, source:)
155
+ # `fulfillment_adapter:` is only ever set by #adapter_flow (the
156
+ # own-store loopback path) — #native_flow has no raw Adapter object to
157
+ # inspect for `fulfillment_supported?`, just a Session talking to a
158
+ # remote store, so shipping-address/rate selection stays loopback-only
159
+ # for now rather than guessing at a wire shape no real UCP server has
160
+ # confirmed (see docs/design-log.md).
161
+ def full_buy(session, source:, fulfillment_adapter: nil)
156
162
  products = safe_search(session)
157
163
  product = select_product(products)
158
164
  unless product
@@ -160,10 +166,68 @@ module Portage
160
166
  message: no_match_message)
161
167
  end
162
168
 
163
- checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }])
169
+ checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
170
+ fulfillment: requested_fulfillment(fulfillment_adapter))
171
+ checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
164
172
  finish_checkout(session, source, products, checkout)
165
173
  end
166
174
 
175
+ # Submits PORTAGE_SHIP_* (see Portage::Cli::ShippingProfile) as the
176
+ # checkout's shipping destination, but only when the adapter actually
177
+ # advertises dev.ucp.shopping.fulfillment — an adapter that doesn't
178
+ # override #fulfillment_supported? would just ignore the param anyway
179
+ # (Adapter#create_checkout's default), but there's no point building it
180
+ # at all in that case.
181
+ def requested_fulfillment(adapter)
182
+ return nil unless adapter && Portage::Ucp::Capabilities::FULFILLMENT.advertised_for?(adapter)
183
+
184
+ address = Portage::Cli::ShippingProfile.from_env
185
+ return nil unless address
186
+
187
+ Portage::Ucp::CheckoutFulfillment.new(
188
+ shipping_methods: [Portage::Ucp::FulfillmentMethod.new(
189
+ id: "requested", type: "shipping", line_item_ids: [],
190
+ destinations: [Portage::Ucp::ShippingDestination.new(id: "current", address: address)]
191
+ )]
192
+ )
193
+ end
194
+
195
+ # Once the merchant has priced options against the submitted address,
196
+ # auto-picks the cheapest unselected option per fulfillment group and
197
+ # submits it via #update_checkout — no interactive rate picker; a CLI
198
+ # driving a single automated purchase needs a deterministic default,
199
+ # not a prompt. A checkout with no fulfillment groups at all (no
200
+ # address was submitted, or the merchant hasn't priced anything yet)
201
+ # passes through unchanged.
202
+ def select_cheapest_shipping(session, checkout)
203
+ groups = checkout.dig("fulfillment", "methods", 0, "groups") || []
204
+ selections = groups.filter_map { |g| cheapest_option_selection(g) }
205
+ return checkout if selections.empty?
206
+
207
+ session.update_checkout(
208
+ checkout_id: checkout["id"], line_items: current_line_items(checkout),
209
+ fulfillment: Portage::Ucp::CheckoutFulfillment.new(
210
+ shipping_methods: [Portage::Ucp::FulfillmentMethod.new(id: "requested", type: "shipping",
211
+ line_item_ids: [], groups: selections)]
212
+ )
213
+ )
214
+ end
215
+
216
+ def cheapest_option_selection(group)
217
+ return nil if group["selected_option_id"] || (group["options"] || []).empty?
218
+
219
+ cheapest = group["options"].min_by { |o| o.dig("totals", 0, "amount") || 0 }
220
+ Portage::Ucp::FulfillmentGroup.new(id: group["id"], line_item_ids: group["line_item_ids"],
221
+ selected_option_id: cheapest["id"])
222
+ end
223
+
224
+ # Checkout's line_items are response-shaped ({item: {id, ...}, ...});
225
+ # #update_checkout takes request-shaped hashes, same asymmetry
226
+ # #product_id_of already handles for search results.
227
+ def current_line_items(checkout)
228
+ checkout["line_items"].map { |li| { product_id: li.dig("item", "id"), quantity: li["quantity"] } }
229
+ end
230
+
167
231
  def no_match_message
168
232
  return "No product matched \"#{@query}\"." unless @product_id
169
233
 
@@ -179,12 +243,13 @@ module Portage
179
243
  products.find { |product| product_id_of(product) == @product_id }
180
244
  end
181
245
 
182
- # search_catalog's results are raw Portage::Ucp::Product structs over
183
- # the loopback transport (Product has no #to_wire_h, see
184
- # Dispatcher#wrap) but string-keyed wire hashes over stdio/HTTP (the
185
- # `mcp` gem's client parses real JSON) handle both.
246
+ # #select_product only ever sees products from #safe_search, which reads
247
+ # through a Session native remote or the own-store loopback session
248
+ # built via Client.for_adapter alike so Dispatcher#wrap has already
249
+ # called #to_wire_h on every result; a string-keyed wire hash either
250
+ # way, never a raw Portage::Ucp::Product struct.
186
251
  def product_id_of(product)
187
- product.respond_to?(:id) ? product.id : product["id"]
252
+ product["id"]
188
253
  end
189
254
 
190
255
  def finish_checkout(session, source, products, checkout)
@@ -235,7 +300,7 @@ module Portage
235
300
  end
236
301
 
237
302
  def safe_search(session)
238
- Array(session.search_catalog(query: @query, limit: 10))
303
+ CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
239
304
  end
240
305
 
241
306
  # --- Homepage fetch (used by both the manifest-not-found path and the
@@ -0,0 +1,29 @@
1
+ module Portage
2
+ module Cli
3
+ # Unwraps whatever shape a catalog search call actually returns.
4
+ #
5
+ # Every call that goes through a Portage::Ucp::Client::Session — native
6
+ # remote stores and the own-store loopback session built via
7
+ # Client.for_adapter alike — passes through Dispatcher#wrap, which calls
8
+ # `to_wire_h` on the result before handing it back. So a Session's
9
+ # `search_catalog` always comes back as the wire-shaped envelope
10
+ # `{"ucp" => ..., "products" => [...]}`, never a bare array and never a
11
+ # raw struct.
12
+ #
13
+ # The one exception is Buy's own-store catalog-only path
14
+ # (`Buy#catalog_only_adapter`), which calls `Adapter#search_catalog`
15
+ # directly — no Session, no Dispatcher — and gets back the real
16
+ # `Portage::Ucp::CatalogSearchResult` the adapter built.
17
+ module CatalogProducts
18
+ module_function
19
+
20
+ def from(result)
21
+ case result
22
+ when Portage::Ucp::CatalogSearchResult then result.products
23
+ when Hash then Array(result["products"])
24
+ else Array(result)
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,160 @@
1
+ require_relative "find"
2
+
3
+ module Portage
4
+ module Cli
5
+ # `portage compare <url> --product-id ID` — §22's "find this same item
6
+ # elsewhere" mode. Resolves a product a shopper already has in hand, then
7
+ # runs Find's own candidate-discovery/probe/rank pipeline against the
8
+ # product's own title, scoring each surviving offer by how confident we
9
+ # actually are that it's the *same* item rather than just a similarly
10
+ # titled one.
11
+ #
12
+ # Catalog-price only: nothing here calls create_checkout against a
13
+ # candidate store, so a ranked offer's price is the listed price, never a
14
+ # landed price. See docs/plans/portage-compare.md for why that's a
15
+ # deliberate scope cut, not an oversight.
16
+ class Compare < Find
17
+ RESULT_LIMIT = 5
18
+ TIER_RANK = { confirmed: 0, likely: 1, unconfirmed: 2 }.freeze
19
+
20
+ # `identity:` is one repeatable value, not four aliases (--mpn/--sku/
21
+ # --upc/--gtin) for the same untyped string corpus — the wire format
22
+ # doesn't distinguish barcode types, so a flag name implying it could
23
+ # would be lying about the matcher's actual precision. `find_options:`
24
+ # bundles Find's own backends:/cache:/throttle: pass-through so this
25
+ # initializer stays under Metrics/ParameterLists without an exclusion.
26
+ def initialize(origin_url:, origin_product_id:, identity: [], results: RESULT_LIMIT, max_price: nil,
27
+ find_options: {})
28
+ @origin_url = origin_url
29
+ @origin_product_id = origin_product_id
30
+ @explicit_identity = identity
31
+ @result_limit = results
32
+ # `query: nil` — the real query isn't known until #call resolves the
33
+ # origin product; Find reads @query at #call time, not construction
34
+ # time, so #call can overwrite it before invoking Find#call via super.
35
+ super(query: nil, max_price: max_price, **find_options)
36
+ end
37
+
38
+ def call
39
+ origin = resolve_origin
40
+ return origin if origin[:message]
41
+
42
+ @origin_host = origin[:host]
43
+ @identity = build_identity(origin[:product])
44
+ @query = origin[:title]
45
+ result = super
46
+ finish(result)
47
+ end
48
+
49
+ private
50
+
51
+ # --- Origin resolution ---
52
+
53
+ def resolve_origin
54
+ uri = parse_http(@origin_url)
55
+ return report(message: "Not a store URL: #{@origin_url.inspect}") unless uri
56
+
57
+ session = discover(origin_of(uri))
58
+ return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
59
+
60
+ wrapped = session.get_product(product_id: @origin_product_id)
61
+ product = wrapped.is_a?(Hash) ? wrapped["product"] : nil
62
+ return report(message: "Product #{@origin_product_id.inspect} not found at #{origin_of(uri)}.") unless product
63
+
64
+ { host: uri.host, title: field(product, "title"), product: product }
65
+ end
66
+
67
+ # --- Identity ---
68
+
69
+ # Explicit `--id` values win when given; otherwise fall back to the
70
+ # origin product's own first variant sku/barcodes. First-variant-only,
71
+ # same scope Find's own single-product reads already assume.
72
+ def build_identity(product)
73
+ variant = first_variant(product)
74
+ { barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]),
75
+ explicit: normalize_all(@explicit_identity) }
76
+ end
77
+
78
+ def first_variant(product) = Array(field(product, "variants")).first || {}
79
+
80
+ def barcode_values(variant) = Array(variant["barcodes"]).filter_map { |b| b.is_a?(Hash) ? b["value"] : nil }
81
+
82
+ def normalize(value)
83
+ value = value.to_s.strip
84
+ value.empty? ? nil : value.downcase
85
+ end
86
+
87
+ def normalize_all(values) = values.map { |v| normalize(v) }.compact.uniq
88
+
89
+ # --- Candidate offers: stash identity + host for scoring/exclusion ---
90
+
91
+ # Find#offer only flattens id/title/price/url off the top-level
92
+ # product. Compare additionally needs each candidate's own variant
93
+ # sku/barcodes (to score against) and a normalized store host (to
94
+ # exclude the origin store by host rather than by string equality on
95
+ # `store:`, which misses scheme/www differences) — kept scoped to
96
+ # Compare rather than pushed into Find, since plain `find` has no use
97
+ # for either.
98
+ def offer(store, product)
99
+ base = super
100
+ return nil unless base
101
+
102
+ variant = first_variant(product)
103
+ identity_values = { barcodes: normalize_all(barcode_values(variant)), sku: normalize(variant["sku"]) }
104
+ base.merge(identity_values: identity_values, store_host: parse_http(store[:origin])&.host)
105
+ end
106
+
107
+ # --- Finish: exclude origin, score, rank, truncate, rewrite message ---
108
+
109
+ def finish(result)
110
+ offers = result[:offers].reject { |o| same_host?(o[:store_host], @origin_host) }
111
+ excluded_origin = result[:offers].length - offers.length
112
+ scored = offers.map { |o| o.merge(match: match_tier(o)) }
113
+ ranked = scored.sort_by { |o| [TIER_RANK[o[:match]], o[:checkout] ? 0 : 1, o[:amount] || 0] }
114
+ kept = ranked.first(@result_limit)
115
+ result.merge(offers: kept, message: compare_summary(kept, ranked.length - kept.length, excluded_origin))
116
+ end
117
+
118
+ def same_host?(candidate_host, origin_host)
119
+ candidate_host && origin_host && candidate_host.downcase == origin_host.downcase
120
+ end
121
+
122
+ # Three tiers, honest about what the wire format can actually prove:
123
+ # barcodes are the one value the spec treats as globally unique, so
124
+ # only a shared barcode earns :confirmed. A shared SKU is merchant-
125
+ # internal and collides across stores constantly, so it — and an
126
+ # explicit --id hit that isn't a barcode match — lands in :likely.
127
+ # Everything else rode in on the search query alone.
128
+ def match_tier(offer)
129
+ candidate = offer[:identity_values]
130
+ return :confirmed if shared_barcode?(candidate[:barcodes])
131
+ return :likely if shared_sku?(candidate[:sku]) || explicit_hit?(candidate)
132
+
133
+ :unconfirmed
134
+ end
135
+
136
+ def shared_barcode?(candidate_barcodes) = @identity[:barcodes].intersect?(Array(candidate_barcodes))
137
+
138
+ def shared_sku?(candidate_sku) = @identity[:sku] && candidate_sku && @identity[:sku] == candidate_sku
139
+
140
+ def explicit_hit?(candidate)
141
+ @identity[:explicit].intersect?((Array(candidate[:barcodes]) + [candidate[:sku]]).compact)
142
+ end
143
+
144
+ def compare_summary(kept, truncated, excluded_origin)
145
+ return empty_compare_summary(excluded_origin) if kept.empty?
146
+
147
+ parts = ["Found #{kept.length} offer(s) for \"#{@query}\""]
148
+ parts << "#{truncated} more not shown (--results #{@result_limit})" if truncated.positive?
149
+ parts << "#{excluded_origin} excluded (origin store)" if excluded_origin.positive?
150
+ "#{parts.join(', ')}."
151
+ end
152
+
153
+ def empty_compare_summary(excluded_origin)
154
+ message = "No comparable offers found for \"#{@query}\" outside #{@origin_host}"
155
+ message += ", #{excluded_origin} excluded (origin store)" if excluded_origin.positive?
156
+ "#{message}."
157
+ end
158
+ end
159
+ end
160
+ end
@@ -135,7 +135,7 @@ module Portage
135
135
  # --- Step 3: ask the survivors what they stock ---
136
136
 
137
137
  def offers_for(store)
138
- products = Array(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
138
+ products = CatalogProducts.from(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
139
139
  products.filter_map { |product| offer(store, product) }
140
140
  rescue StandardError
141
141
  []
@@ -161,10 +161,12 @@ module Portage
161
161
 
162
162
  # --- Shapes ---
163
163
 
164
- # Products arrive as `Portage::Ucp::Product` structs over the loopback
165
- # transport and as string-keyed wire hashes over stdio/HTTP (same split
166
- # Buy#product_id_of documents), and the wire shape carries a
167
- # `price_range` rather than a scalar price.
164
+ # Every product here comes from #offers_for, which reads through
165
+ # Session#search_catalog Dispatcher#wrap has already called
166
+ # #to_wire_h on the result, so this is always a string-keyed wire hash,
167
+ # never a raw Portage::Ucp::Product struct (same posture as
168
+ # Buy#product_id_of), and it carries a `price_range` rather than a
169
+ # scalar price.
168
170
  def price_of(product)
169
171
  range = field(product, "price_range")
170
172
  return [money_amount(range["min"]), range["min"]["currency"]] if range.is_a?(Hash) && range["min"].is_a?(Hash)
@@ -184,11 +186,7 @@ module Portage
184
186
 
185
187
  def money_amount(price) = price["amount"]
186
188
 
187
- def field(product, key)
188
- return product[key] if product.is_a?(Hash)
189
-
190
- product.respond_to?(key) ? product.public_send(key) : nil
191
- end
189
+ def field(product, key) = product[key]
192
190
 
193
191
  def report(**fields)
194
192
  { query: @query, candidates: [], stores: [], offers: [], message: nil }.merge(fields)
@@ -0,0 +1,72 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Local record of what `portage` has searched for and bought, entirely
7
+ # separate from ProbeCache (which remembers *hosts*, not *actions*). Two
8
+ # append-only, size-capped lists — purchases and searches — so `portage
9
+ # history list` can answer "what did I already look for" and "what did I
10
+ # already buy" without re-running anything, and `portage history clear`
11
+ # can wipe either or both.
12
+ class History
13
+ PATH = File.join(Dir.home, ".portage", "history.json").freeze
14
+ MAX_ENTRIES = 200
15
+
16
+ def initialize(path: PATH, now: Time.now)
17
+ @path = path
18
+ @now = now.to_i
19
+ end
20
+
21
+ def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
22
+ append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
23
+ "checkout_status" => checkout_status, "message" => message,
24
+ "products" => products, "at" => @now })
25
+ end
26
+
27
+ def record_search(query:, offer_count:, message:)
28
+ append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
29
+ end
30
+
31
+ def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
32
+
33
+ def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
34
+
35
+ # @param kind [String, nil] "purchases", "searches", or nil for both.
36
+ def clear(kind: nil)
37
+ kinds = kind ? [kind] : %w[purchases searches]
38
+ kinds.each { |k| store[k] = [] }
39
+ write
40
+ end
41
+
42
+ private
43
+
44
+ def append(kind, entry)
45
+ store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
46
+ write
47
+ entry
48
+ end
49
+
50
+ def store
51
+ @store ||= read
52
+ end
53
+
54
+ def read
55
+ parsed = File.readable?(@path) ? JSON.parse(File.read(@path)) : {}
56
+ parsed = {} unless parsed.is_a?(Hash)
57
+ { "purchases" => Array(parsed["purchases"]), "searches" => Array(parsed["searches"]) }
58
+ rescue StandardError
59
+ { "purchases" => [], "searches" => [] }
60
+ end
61
+
62
+ # A history that can't be written just doesn't remember this run —
63
+ # never a failed buy or search.
64
+ def write
65
+ FileUtils.mkdir_p(File.dirname(@path))
66
+ File.write(@path, JSON.generate(@store))
67
+ rescue StandardError
68
+ nil
69
+ end
70
+ end
71
+ end
72
+ end
@@ -0,0 +1,32 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # Reads a shopper's default shipping address from the process environment
6
+ # — the same "your own credentials live in env, not on the command line"
7
+ # posture Resolver.env_for already uses for adapter credentials, applied
8
+ # here to a buyer-profile concern instead of a platform-credential one
9
+ # (so it lives in portage-cli, not Portage::Ucp::Resolver).
10
+ module ShippingProfile
11
+ ENV_VARS = {
12
+ street_address: "PORTAGE_SHIP_STREET", extended_address: "PORTAGE_SHIP_EXTENDED",
13
+ address_locality: "PORTAGE_SHIP_CITY", address_region: "PORTAGE_SHIP_REGION",
14
+ address_country: "PORTAGE_SHIP_COUNTRY", postal_code: "PORTAGE_SHIP_POSTAL_CODE",
15
+ first_name: "PORTAGE_SHIP_FIRST_NAME", last_name: "PORTAGE_SHIP_LAST_NAME",
16
+ phone_number: "PORTAGE_SHIP_PHONE"
17
+ }.freeze
18
+
19
+ REQUIRED = %i[street_address address_locality address_country postal_code].freeze
20
+
21
+ # @return [Portage::Ucp::PostalAddress, nil] nil unless every required
22
+ # field is set — a partial profile isn't enough to submit, and this
23
+ # module never guesses at a missing field.
24
+ def self.from_env
25
+ attrs = ENV_VARS.filter_map { |key, var| [key, ENV.fetch(var, nil)] if ENV.key?(var) }.to_h
26
+ return nil unless REQUIRED.all? { |key| attrs.key?(key) }
27
+
28
+ Portage::Ucp::PostalAddress.new(**attrs)
29
+ end
30
+ end
31
+ end
32
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.1.0".freeze
3
+ VERSION = "0.3.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -2,8 +2,12 @@ require "optparse"
2
2
  require "json"
3
3
 
4
4
  require_relative "cli/version"
5
+ require_relative "cli/shipping_profile"
6
+ require_relative "cli/catalog_products"
5
7
  require_relative "cli/buy"
6
8
  require_relative "cli/find"
9
+ require_relative "cli/compare"
10
+ require_relative "cli/history"
7
11
 
8
12
  module Portage
9
13
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -16,6 +20,10 @@ module Portage
16
20
  [--product-id ID] [--yes] [--dry-run] [--json]
17
21
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
18
22
  portage find --query "..." [--max-price N] [--limit N] [--json]
23
+ portage compare <url> --product-id ID [--id VALUE ...] [--results N]
24
+ [--max-price N] [--json]
25
+ portage history [list] [--purchases|--searches] [--limit N] [--json]
26
+ portage history clear [--purchases|--searches]
19
27
  USAGE
20
28
 
21
29
  # @param argv [Array<String>]
@@ -25,6 +33,8 @@ module Portage
25
33
  case command
26
34
  when "buy" then run_buy(rest)
27
35
  when "find" then run_find(rest)
36
+ when "compare" then run_compare(rest)
37
+ when "history" then run_history(rest)
28
38
  else
29
39
  warn USAGE
30
40
  1
@@ -39,6 +49,8 @@ module Portage
39
49
 
40
50
  json = options.delete(:json)
41
51
  report = Find.new(**options).call
52
+ History.new.record_search(query: report[:query], offer_count: report[:offers].length,
53
+ message: report[:message])
42
54
  puts json ? JSON.pretty_generate(report) : format_find(report)
43
55
  report[:offers].any? ? 0 : 1
44
56
  end
@@ -72,6 +84,50 @@ module Portage
72
84
  def self.to_minor_units(major) = (major * 100).round
73
85
  private_class_method :to_minor_units
74
86
 
87
+ # --- compare ---
88
+
89
+ def self.run_compare(argv)
90
+ options = parse_compare_options(argv)
91
+ return 1 unless options
92
+
93
+ json = options.delete(:json)
94
+ url = options.delete(:url)
95
+ report = Compare.new(origin_url: url, **options).call
96
+ # Recorded as a search, not a purchase — compare never checks out. The
97
+ # query string names the compare so `portage history list` doesn't
98
+ # read it as a plain text search for the origin product's own title.
99
+ History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
100
+ offer_count: report[:offers].length, message: report[:message])
101
+ puts json ? JSON.pretty_generate(report) : format_compare(report)
102
+ report[:offers].any? ? 0 : 1
103
+ end
104
+ private_class_method :run_compare
105
+
106
+ def self.parse_compare_options(argv)
107
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
108
+ opts = { identity: [] }
109
+ compare_option_parser(opts).parse!(argv)
110
+ if !url || opts[:origin_product_id].to_s.strip.empty?
111
+ warn USAGE
112
+ return nil
113
+ end
114
+
115
+ opts[:url] = url
116
+ opts
117
+ end
118
+ private_class_method :parse_compare_options
119
+
120
+ def self.compare_option_parser(opts)
121
+ OptionParser.new do |parser|
122
+ parser.on("--product-id ID") { |v| opts[:origin_product_id] = v }
123
+ parser.on("--id VALUE") { |v| opts[:identity] << v }
124
+ parser.on("--results N", Integer) { |v| opts[:results] = v }
125
+ parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
126
+ parser.on("--json") { opts[:json] = true }
127
+ end
128
+ end
129
+ private_class_method :compare_option_parser
130
+
75
131
  # --- buy ---
76
132
 
77
133
  def self.run_buy(argv)
@@ -121,11 +177,23 @@ module Portage
121
177
  options = parsed[:buy].merge(url: url)
122
178
  options[:product_id] ||= product_id
123
179
  report = Buy.new(**options).call
180
+ record_purchase(report, options[:query]) if report[:checkout]
124
181
  puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
125
182
  report[:checkout] || report[:browse] ? 0 : 1
126
183
  end
127
184
  private_class_method :execute_buy
128
185
 
186
+ # Only checkout attempts land here — a browse-only report never reached a
187
+ # checkout, so it belongs to search history, not purchase history.
188
+ def self.record_purchase(report, query)
189
+ History.new.record_purchase(
190
+ url: report[:url], query: query, checkout: report[:checkout],
191
+ checkout_status: report[:checkout_status], message: report[:message],
192
+ products: report[:products].map { |p| product_line(p) }
193
+ )
194
+ end
195
+ private_class_method :record_purchase
196
+
129
197
  def self.parse_buy_options(argv)
130
198
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
131
199
  buy = { url: url, qty: 1, yes: false, dry_run: false }
@@ -163,6 +231,73 @@ module Portage
163
231
  end
164
232
  private_class_method :add_search_options
165
233
 
234
+ # --- history ---
235
+
236
+ def self.run_history(argv)
237
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : "list"
238
+ case sub
239
+ when "list" then run_history_list(argv)
240
+ when "clear" then run_history_clear(argv)
241
+ else
242
+ warn USAGE
243
+ 1
244
+ end
245
+ end
246
+ private_class_method :run_history
247
+
248
+ def self.run_history_list(argv)
249
+ opts = { limit: History::MAX_ENTRIES }
250
+ history_option_parser(opts).parse!(argv)
251
+ json = opts.delete(:json)
252
+ kind = opts.delete(:kind)
253
+ history = History.new
254
+ result = { purchases: kind == "searches" ? [] : history.purchases(limit: opts[:limit]),
255
+ searches: kind == "purchases" ? [] : history.searches(limit: opts[:limit]) }
256
+ puts json ? JSON.pretty_generate(result) : format_history(result)
257
+ 0
258
+ end
259
+ private_class_method :run_history_list
260
+
261
+ def self.run_history_clear(argv)
262
+ opts = {}
263
+ history_option_parser(opts).parse!(argv)
264
+ History.new.clear(kind: opts[:kind])
265
+ puts "Cleared #{opts[:kind] || 'purchase and search'} history."
266
+ 0
267
+ end
268
+ private_class_method :run_history_clear
269
+
270
+ def self.history_option_parser(opts)
271
+ OptionParser.new do |parser|
272
+ parser.on("--purchases") { opts[:kind] = "purchases" }
273
+ parser.on("--searches") { opts[:kind] = "searches" }
274
+ parser.on("--limit N", Integer) { |v| opts[:limit] = v }
275
+ parser.on("--json") { opts[:json] = true }
276
+ end
277
+ end
278
+ private_class_method :history_option_parser
279
+
280
+ def self.format_history(result)
281
+ lines = ["Purchases:"]
282
+ result[:purchases].each { |p| lines << " #{history_purchase_line(p)}" }
283
+ lines << "(none)" if result[:purchases].empty?
284
+ lines << "Searches:"
285
+ result[:searches].each { |s| lines << " #{history_search_line(s)}" }
286
+ lines << "(none)" if result[:searches].empty?
287
+ lines.join("\n")
288
+ end
289
+ private_class_method :format_history
290
+
291
+ def self.history_purchase_line(entry)
292
+ "#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
293
+ end
294
+ private_class_method :history_purchase_line
295
+
296
+ def self.history_search_line(entry)
297
+ "#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
298
+ end
299
+ private_class_method :history_search_line
300
+
166
301
  # --- output ---
167
302
 
168
303
  def self.format_report(report)
@@ -192,6 +327,20 @@ module Portage
192
327
  end
193
328
  private_class_method :offer_line
194
329
 
330
+ def self.format_compare(report)
331
+ lines = [report[:message].to_s]
332
+ report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
333
+ lines.join("\n")
334
+ end
335
+ private_class_method :format_compare
336
+
337
+ def self.compare_offer_line(offer)
338
+ parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
339
+ parts << "browse only" unless offer[:checkout]
340
+ parts.join(" — ")
341
+ end
342
+ private_class_method :compare_offer_line
343
+
195
344
  def self.format_price(offer)
196
345
  return "price n/a" unless offer[:amount]
197
346
 
metadata CHANGED
@@ -1,14 +1,13 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.1.0
4
+ version: 0.3.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
- autorequire:
9
8
  bindir: exe
10
9
  cert_chain: []
11
- date: 2026-08-14 00:00:00.000000000 Z
10
+ date: 1980-01-02 00:00:00.000000000 Z
12
11
  dependencies:
13
12
  - !ruby/object:Gem::Dependency
14
13
  name: portage-ucp
@@ -16,28 +15,28 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0.1'
18
+ version: '0.4'
20
19
  type: :runtime
21
20
  prerelease: false
22
21
  version_requirements: !ruby/object:Gem::Requirement
23
22
  requirements:
24
23
  - - "~>"
25
24
  - !ruby/object:Gem::Version
26
- version: '0.1'
25
+ version: '0.4'
27
26
  - !ruby/object:Gem::Dependency
28
27
  name: portage-ucp-client
29
28
  requirement: !ruby/object:Gem::Requirement
30
29
  requirements:
31
30
  - - "~>"
32
31
  - !ruby/object:Gem::Version
33
- version: '0.1'
32
+ version: '0.2'
34
33
  type: :runtime
35
34
  prerelease: false
36
35
  version_requirements: !ruby/object:Gem::Requirement
37
36
  requirements:
38
37
  - - "~>"
39
38
  - !ruby/object:Gem::Version
40
- version: '0.1'
39
+ version: '0.2'
41
40
  - !ruby/object:Gem::Dependency
42
41
  name: rspec
43
42
  requirement: !ruby/object:Gem::Requirement
@@ -104,7 +103,6 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
104
103
  ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
105
104
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
106
105
  portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
107
- email:
108
106
  executables:
109
107
  - portage
110
108
  extensions: []
@@ -116,9 +114,13 @@ files:
116
114
  - exe/portage
117
115
  - lib/portage/cli.rb
118
116
  - lib/portage/cli/buy.rb
117
+ - lib/portage/cli/catalog_products.rb
118
+ - lib/portage/cli/compare.rb
119
119
  - lib/portage/cli/find.rb
120
+ - lib/portage/cli/history.rb
120
121
  - lib/portage/cli/probe_cache.rb
121
122
  - lib/portage/cli/search_backends.rb
123
+ - lib/portage/cli/shipping_profile.rb
122
124
  - lib/portage/cli/version.rb
123
125
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
124
126
  licenses:
@@ -127,7 +129,6 @@ metadata:
127
129
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
128
130
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
129
131
  rubygems_mfa_required: 'true'
130
- post_install_message:
131
132
  rdoc_options: []
132
133
  require_paths:
133
134
  - lib
@@ -142,8 +143,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
142
143
  - !ruby/object:Gem::Version
143
144
  version: '0'
144
145
  requirements: []
145
- rubygems_version: 3.5.22
146
- signing_key:
146
+ rubygems_version: 3.6.9
147
147
  specification_version: 4
148
148
  summary: portage — one CLI command to buy from any store, native UCP or not
149
149
  test_files: []