portage-cli 0.1.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 ADDED
@@ -0,0 +1,7 @@
1
+ ---
2
+ SHA256:
3
+ metadata.gz: a4a32f5894d104c340e147d698772d21492c3172d6b8e102c4508e431d210a9e
4
+ data.tar.gz: dbf75ae4f0c5bd7996095d40c0a7e019d4cf9e0376b529356e227d5879363a4a
5
+ SHA512:
6
+ metadata.gz: e41a7814ccdf875fae65a3ec9001c678e6cd0f951c504a0f675d3a34c3126349486df39919e514056b2529305d6560745ca4e028269c69b58e60d6610465f3fc
7
+ data.tar.gz: 75503266fb054b39739bcd2786083454e0afdefae6e64aaa2573ebc6b4633fd4c3956b16c61707cdbb3bc688a56e31734c4b04293678473b8ffc776a80ceb4d5
data/CHANGELOG.md ADDED
@@ -0,0 +1,21 @@
1
+ # Changelog
2
+
3
+ All notable changes to this project are documented here. Format loosely follows
4
+ [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
+ pre-1.0, so APIs may still shift between minor versions.
6
+
7
+ ## [0.1.0] - Unreleased
8
+
9
+ - Initial pre-release. `portage buy <url>` — native UCP discovery first,
10
+ adapter fallback only when this process already has that platform's own
11
+ credentials.
12
+ - `portage find --query "..."` — find UCP stores that stock something without
13
+ knowing a URL, via an allowlist, DuckDuckGo, Brave, or a Google Programmable
14
+ Search engine, then probe each candidate for `/.well-known/ucp` and search
15
+ the survivors' catalogs. Probe results are cached in
16
+ `~/.portage/discovery-cache.json`.
17
+ - `portage buy` with no URL runs that search and buys the offer you pick.
18
+ `--yes` alone won't buy from a search result: the merchant has to be named by
19
+ `--store` or an interactive pick.
20
+ - `portage buy --product-id ID` buys exactly that product instead of whatever
21
+ the catalog search ranks first.
data/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Tom Whitbread
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
data/README.md ADDED
@@ -0,0 +1,129 @@
1
+ # portage-cli
2
+
3
+ Ships the `portage` executable — one CLI command to buy from any store, native
4
+ UCP or not.
5
+
6
+ ```bash
7
+ portage buy https://your-shop.example --query "snowboard" --qty 1 --payment-token spt_1a2b3c...
8
+ ```
9
+
10
+ `portage buy <url>`:
11
+
12
+ 1. Tries native UCP discovery first (`GET /.well-known/ucp`, then a `<link
13
+ rel="ucp">`-style tag on the homepage) — zero credentials, works on any
14
+ store that's opted in.
15
+ 2. Falls back to a `portage-ucp-<platform>` adapter **only** when this process
16
+ already has that platform's own credentials in env — i.e. it's your own
17
+ store, or one you're integrated with.
18
+ 3. Otherwise says so plainly and stops — never scrapes or session-hijacks as an
19
+ anonymous shopper. That fallback path is a ToS violation this gem
20
+ deliberately refuses to take.
21
+
22
+ Don't have a URL? `portage find` asks a search backend which stores might sell
23
+ the thing, keeps the ones that answer `/.well-known/ucp`, and lists what they
24
+ actually stock:
25
+
26
+ ```bash
27
+ portage find --query "burton snowboard" --max-price 400
28
+ ```
29
+
30
+ `portage buy` with no URL runs that search and then buys the offer you pick.
31
+
32
+ Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
33
+ (for platform detection via `Resolver`) and
34
+ [`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
35
+ (for the actual buy calls). No single adapter gem is a hard dependency — install
36
+ whichever `portage-ucp-<platform>` gem matches the store you're integrated with,
37
+ if any.
38
+
39
+ ## Installation
40
+
41
+ ```ruby
42
+ # Gemfile
43
+ gem "portage-cli"
44
+ ```
45
+
46
+ ```bash
47
+ bundle install
48
+ ```
49
+
50
+ Or standalone:
51
+
52
+ ```bash
53
+ gem install portage-cli
54
+ ```
55
+
56
+ ## Usage
57
+
58
+ ```bash
59
+ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
60
+ [--yes] [--dry-run] [--json]
61
+ portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
62
+ portage find --query "..." [--max-price N] [--limit N] [--json]
63
+ ```
64
+
65
+ - `--query` — search term. Against the store's catalog when you name a store,
66
+ against the search backends when you don't.
67
+ - `--qty` — quantity, default `1`.
68
+ - `--payment-token` — a tokenized payment credential (never a raw card number —
69
+ `PaymentTokenGuard` in the core gem rejects those before they reach the wire).
70
+ Omit for `--dry-run` or to just browse.
71
+ - `--product-id` — buy exactly this product rather than whatever the catalog
72
+ search ranks first. If the id isn't in the results, nothing is bought.
73
+ - `--store` — name the merchant without giving a full URL; skips the search.
74
+ - `--max-price` — in major units (`400` means 400), compared per offer in that
75
+ offer's own currency. No FX conversion.
76
+ - `--limit` — how many candidate stores to probe, capped at 12.
77
+ - `--yes` — skip the confirmation prompt before completing checkout.
78
+ - `--dry-run` — resolve and price the order without completing checkout.
79
+ - `--json` — machine-readable report instead of the human-readable summary.
80
+
81
+ Exits `0` when a checkout completed (or a dry-run/browse/search resolved
82
+ successfully), `1` otherwise — including the "no native manifest, no adapter
83
+ credentials" dead-end case, so it's scriptable in CI.
84
+
85
+ ## Buying without a URL
86
+
87
+ `portage find` and URL-less `portage buy` share one pipeline:
88
+
89
+ 1. **Ask the backends** which stores might sell it (see below).
90
+ 2. **Probe each candidate origin** for `/.well-known/ucp`, one request each,
91
+ throttled, with results cached in `~/.portage/discovery-cache.json` (misses
92
+ for a day, hits for six hours) so repeat searches don't re-probe the same
93
+ hosts. A tool that fans out an unsolicited request per host per invocation
94
+ is a crawler; this one isn't.
95
+ 3. **Search the survivors' catalogs** and merge the offers, buyable stores
96
+ first, then cheapest.
97
+
98
+ **`--yes` is not enough to buy from a search result.** With a URL you chose the
99
+ merchant; without one a search ranker chose it, so the merchant has to be named
100
+ by a person — either `--store`, or an interactive pick from the listed offers.
101
+ A piped or CI run with no `--store` prints the offers and stops.
102
+
103
+ ### Search backends
104
+
105
+ Every backend talks to a documented API. None of them parse a results page:
106
+ scraping a search engine is the same class of ToS violation `portage buy`
107
+ already refuses to commit against a merchant.
108
+
109
+ | Backend | Credentials | Notes |
110
+ | --- | --- | --- |
111
+ | Allowlist | `~/.portage/stores.yml` (YAML array of URLs) or `PORTAGE_STORES` (comma-separated) | Stores you already trust. Checked first, costs no network call. |
112
+ | DuckDuckGo | none | The [Instant Answer API](https://api.duckduckgo.com/api). Answers *entity* queries, not web queries: `burton snowboards` resolves to burton.com, `snowboard` resolves to nothing. |
113
+ | Brave | `BRAVE_SEARCH_API_KEY` | Real web results. Set this up if you want open-ended queries to work. |
114
+ | Google | `GOOGLE_CSE_KEY` + `GOOGLE_CSE_CX` | Programmable Search JSON API. |
115
+
116
+ Backends that have no credentials sit out; DuckDuckGo is the keyless default
117
+ because it's the only no-key engine with a real API, and its narrowness is the
118
+ price of not scraping.
119
+
120
+ ## Development
121
+
122
+ ```bash
123
+ bundle exec rspec
124
+ bundle exec rubocop
125
+ ```
126
+
127
+ ## License
128
+
129
+ [MIT](LICENSE) — Copyright (c) 2026 Tom Whitbread.
data/exe/portage ADDED
@@ -0,0 +1,6 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "portage/cli"
5
+
6
+ exit(Portage::Cli.run(ARGV))
@@ -0,0 +1,282 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "portage/ucp"
5
+ require "portage/ucp/client"
6
+
7
+ module Portage
8
+ module Cli
9
+ # `portage buy <url>` — the single entrypoint for "get this thing bought",
10
+ # regardless of whether the target store speaks native UCP, only offers a
11
+ # catalog, or doesn't speak UCP at all but happens to run a platform we
12
+ # have an adapter (and this process's own credentials) for.
13
+ #
14
+ # Never tries to buy as an anonymous shopper via scraping/session-hijacking
15
+ # (ToS violation, explicitly ruled out), and never falls back to an
16
+ # adapter unless this process already has that platform's own env vars
17
+ # set — i.e. it's your own store, or one you're integrated with, never a
18
+ # stranger's. See docs/design-log.md for the reasoning behind it.
19
+ class Buy
20
+ CART_CAP = "dev.ucp.shopping.cart".freeze
21
+ CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
22
+ REDIRECT_LIMIT = 5
23
+
24
+ # @param product_id [String, nil] buy exactly this product instead of
25
+ # whatever the catalog search happens to rank first — how `portage
26
+ # find` hands a picked offer over without the ranking being guessed
27
+ # twice.
28
+ def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil)
29
+ raw = url.to_s.strip
30
+ raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
31
+ @uri = URI.parse(raw)
32
+ @query = query
33
+ @qty = qty
34
+ @payment_token = payment_token
35
+ @yes = yes
36
+ @dry_run = dry_run
37
+ @product_id = product_id
38
+ end
39
+
40
+ def call
41
+ session = discover(@uri)
42
+ return native_flow(session) if session
43
+
44
+ body, headers = fetch_homepage(@uri)
45
+
46
+ linked = manifest_link(body)
47
+ if linked
48
+ session = discover(linked)
49
+ return native_flow(session) if session
50
+ end
51
+
52
+ platform = Portage::Ucp::Resolver.detect_platform(body, headers)
53
+ adapter_flow(platform) || dead_end
54
+ end
55
+
56
+ private
57
+
58
+ # --- Step 1: native UCP manifest ---
59
+
60
+ def discover(url)
61
+ Portage::Ucp::Client.discover(url.to_s)
62
+ rescue Portage::Ucp::Client::DiscoveryError
63
+ nil
64
+ end
65
+
66
+ def native_flow(session)
67
+ if session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
68
+ full_buy(session, source: "native_ucp")
69
+ else
70
+ catalog_only(session)
71
+ end
72
+ end
73
+
74
+ def catalog_only(session)
75
+ products = safe_search(session)
76
+ report = build_report(
77
+ source: "native_ucp", browse: true, checkout: false, products: products,
78
+ message: "I can browse this store but can't check out via UCP yet."
79
+ )
80
+ merge_adapter_checkout_fallback(report)
81
+ end
82
+
83
+ # No manifest advertised checkout — see whether a platform adapter can
84
+ # still cover it (fetches the homepage fresh; the "fetch once" rule in
85
+ # the design only covers the "no manifest at all" branch below, since
86
+ # this is a genuinely separate path).
87
+ def merge_adapter_checkout_fallback(report)
88
+ body, headers = fetch_homepage(@uri)
89
+ platform = Portage::Ucp::Resolver.detect_platform(body, headers)
90
+ fallback = platform && adapter_flow(platform)
91
+ return report unless fallback && fallback[:checkout]
92
+
93
+ report.merge(source: fallback[:source], checkout: true, checkout_url: fallback[:checkout_url])
94
+ end
95
+
96
+ # --- Step 1 fallback: alternate manifest pointer in <head> ---
97
+
98
+ def manifest_link(body)
99
+ return nil unless body
100
+
101
+ match = body.match(/<link[^>]+rel=["']ucp["'][^>]+href=["']([^"']+)["']/i) ||
102
+ body.match(/<link[^>]+href=["']([^"']+)["'][^>]+rel=["']ucp["']/i)
103
+ match && URI.join(@uri, match[1])
104
+ end
105
+
106
+ # --- Step 2/3: platform detection + adapter fallback ---
107
+
108
+ def adapter_flow(platform)
109
+ return nil unless platform
110
+
111
+ env = Portage::Ucp::Resolver.env_for(platform)
112
+ return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
113
+
114
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
115
+ if adapter_supports_checkout?(adapter)
116
+ full_buy(client_for(adapter), source: "adapter:#{platform.name}")
117
+ else
118
+ catalog_only_adapter(adapter, platform)
119
+ end
120
+ rescue LoadError, StandardError
121
+ nil
122
+ end
123
+
124
+ def adapter_supports_checkout?(adapter)
125
+ Portage::Ucp::Capabilities::CART.advertised_for?(adapter) &&
126
+ Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
127
+ end
128
+
129
+ def client_for(adapter)
130
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
131
+ end
132
+
133
+ def catalog_only_adapter(adapter, platform)
134
+ products = Array(adapter.search_catalog(query: @query, limit: 10))
135
+ checkout = redirect_checkout(adapter, products)
136
+ build_report(
137
+ source: "adapter:#{platform.name}", browse: true, checkout: !!checkout,
138
+ products: products, checkout_url: checkout && checkout.links.first&.url,
139
+ message: "Found it on #{platform.name}, but checkout there isn't a live UCP transaction — " \
140
+ "#{checkout ? 'follow the link to buy it yourself.' : 'no checkout path at all.'}"
141
+ )
142
+ end
143
+
144
+ def redirect_checkout(adapter, products)
145
+ return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
146
+
147
+ adapter.create_checkout(line_items: [{ product_id: products.first.id, quantity: @qty }],
148
+ idempotency_key: "portage-buy-#{products.first.id}")
149
+ rescue StandardError
150
+ nil
151
+ end
152
+
153
+ # --- The actual buy, shared by native and adapter-loopback sources ---
154
+
155
+ def full_buy(session, source:)
156
+ products = safe_search(session)
157
+ product = select_product(products)
158
+ unless product
159
+ return build_report(source: source, browse: true, checkout: true, products: products,
160
+ message: no_match_message)
161
+ end
162
+
163
+ checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }])
164
+ finish_checkout(session, source, products, checkout)
165
+ end
166
+
167
+ def no_match_message
168
+ return "No product matched \"#{@query}\"." unless @product_id
169
+
170
+ "Product #{@product_id} isn't in this store's results for \"#{@query}\"."
171
+ end
172
+
173
+ # With a --product-id, that exact product or nothing: falling back to the
174
+ # top search hit when the requested id isn't in the results would buy
175
+ # something the caller never chose.
176
+ def select_product(products)
177
+ return products.first unless @product_id
178
+
179
+ products.find { |product| product_id_of(product) == @product_id }
180
+ end
181
+
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.
186
+ def product_id_of(product)
187
+ product.respond_to?(:id) ? product.id : product["id"]
188
+ end
189
+
190
+ def finish_checkout(session, source, products, checkout)
191
+ status = checkout["status"]
192
+ return escalation_report(source, products, checkout) if status == "requires_escalation"
193
+ return dry_run_report(source, products, checkout) if @dry_run
194
+ return confirmation_needed_report(source, products, checkout) unless confirmed?
195
+
196
+ complete(session, source, products, checkout)
197
+ end
198
+
199
+ def complete(session, source, products, checkout)
200
+ unless @payment_token
201
+ return checkout_report(source, products, checkout,
202
+ message: "No --payment-token given — can't complete the purchase.")
203
+ end
204
+
205
+ completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
206
+ checkout_report(source, products, completed, message: "Purchased.")
207
+ end
208
+
209
+ def confirmed?
210
+ @yes
211
+ end
212
+
213
+ def escalation_report(source, products, checkout)
214
+ checkout_report(source, products, checkout,
215
+ checkout_url: checkout["links"]&.find { |l| l["url"] }&.fetch("url", nil),
216
+ message: "Checkout requires buyer escalation — visit the link to complete it.")
217
+ end
218
+
219
+ def dry_run_report(source, products, checkout)
220
+ checkout_report(source, products, checkout, message: "Dry run — checkout created but not completed.")
221
+ end
222
+
223
+ def confirmation_needed_report(source, products, checkout)
224
+ checkout_report(source, products, checkout, message: "Checkout ready — pass --yes to confirm the purchase.")
225
+ end
226
+
227
+ # Flattens the parts of a Checkout wire hash a CLI caller actually
228
+ # wants to see (id/status/totals) onto the report, rather than nesting
229
+ # the raw hash under a key that'd collide with the boolean `checkout:`
230
+ # field the output struct already reserves (§ output shape).
231
+ def checkout_report(source, products, checkout, message:, checkout_url: nil)
232
+ build_report(source: source, browse: true, checkout: true, products: products, message: message,
233
+ checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
234
+ totals: checkout["totals"])
235
+ end
236
+
237
+ def safe_search(session)
238
+ Array(session.search_catalog(query: @query, limit: 10))
239
+ end
240
+
241
+ # --- Homepage fetch (used by both the manifest-not-found path and the
242
+ # catalog-only-native adapter-checkout-fallback path) ---
243
+
244
+ def fetch_homepage(uri, limit = REDIRECT_LIMIT)
245
+ return [nil, {}] if limit.zero?
246
+
247
+ response = Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
248
+ open_timeout: 5, read_timeout: 5) do |http|
249
+ http.get(uri.request_uri, { "User-Agent" => "portage-buy" })
250
+ end
251
+
252
+ case response
253
+ when Net::HTTPRedirection
254
+ fetch_homepage(URI.join(uri, response["location"]), limit - 1)
255
+ when Net::HTTPSuccess
256
+ [response.body, response.to_hash]
257
+ else
258
+ [nil, {}]
259
+ end
260
+ rescue StandardError
261
+ [nil, {}]
262
+ end
263
+
264
+ def dead_end
265
+ build_report(source: "none", browse: false, checkout: false,
266
+ message: "No automated path — visit #{@uri} yourself.")
267
+ end
268
+
269
+ def build_report(**fields)
270
+ { url: @uri.to_s, checkout_url: nil, products: [] }.merge(fields)
271
+ end
272
+
273
+ # Loopback buy against your own store needs *some* authenticator (§9
274
+ # rejects anonymous mutation by default) — since this process already
275
+ # has this platform's own credentials (that's the gate to even reach
276
+ # here), authenticating this local CLI session is reasonable.
277
+ class PermissiveAuthenticator < Portage::Ucp::Authenticator
278
+ def call(_server_context) = :local_cli
279
+ end
280
+ end
281
+ end
282
+ end
@@ -0,0 +1,217 @@
1
+ require "uri"
2
+ require "portage/ucp"
3
+ require "portage/ucp/client"
4
+
5
+ require_relative "search_backends"
6
+ require_relative "probe_cache"
7
+
8
+ module Portage
9
+ module Cli
10
+ # `portage find --query "..."` — the "I don't have a URL" half of the CLI.
11
+ #
12
+ # Ask a search backend which stores might sell the thing, keep only the
13
+ # ones that answer `/.well-known/ucp`, ask each of those what it actually
14
+ # stocks, and return the merged offers. Buy then takes over from a store
15
+ # the caller picked.
16
+ #
17
+ # The split matters: Find never buys. Handing the merchant choice to a
18
+ # search ranker and the purchase decision to `--yes` in one breath is how
19
+ # you end up owning a counterfeit from a shop you've never heard of, so
20
+ # picking a store stays an explicit act (see Cli.run_buy's `--store` gate).
21
+ class Find
22
+ CART_CAP = "dev.ucp.shopping.cart".freeze
23
+ CHECKOUT_CAP = "dev.ucp.shopping.checkout".freeze
24
+ MAX_PROBES = 12
25
+ PER_STORE_RESULTS = 5
26
+ THROTTLE = 0.1
27
+
28
+ # @param max_price [Integer, nil] minor units, matching the protocol's
29
+ # own money representation — the CLI converts from major units.
30
+ def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE)
31
+ @query = query.to_s
32
+ @limit = [limit, MAX_PROBES].min
33
+ @max_price = max_price
34
+ @backends = backends || SearchBackends.default
35
+ @cache = cache || ProbeCache.new
36
+ @throttle = throttle
37
+ end
38
+
39
+ def call
40
+ return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
41
+
42
+ candidates = candidate_origins
43
+ return report(candidates: candidates, message: no_candidates_message) if candidates.empty?
44
+
45
+ stores = probe(candidates)
46
+ offers = rank(stores.flat_map { |store| offers_for(store) })
47
+ report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
48
+ offers: offers, message: summary(candidates, stores, offers))
49
+ end
50
+
51
+ private
52
+
53
+ # --- Step 1: ask the backends who might sell this ---
54
+
55
+ def candidate_origins
56
+ seen = {}
57
+ @backends.each do |backend|
58
+ urls_from(backend).each { |url| add_candidate(seen, backend, url) }
59
+ end
60
+ seen.values.first(@limit)
61
+ end
62
+
63
+ # Keyed by host rather than by full origin: backends routinely hand back
64
+ # both `http://` and `https://` for the same shop, and probing one host
65
+ # twice over two schemes is a wasted request every time. https wins when
66
+ # both show up; an http-only host is still probed as it was given. The
67
+ # backend credited stays the one that found the host first.
68
+ def add_candidate(seen, backend, url)
69
+ uri = parse_http(url)
70
+ return unless uri
71
+
72
+ existing = seen[uri.host]
73
+ return if existing && !upgradable?(existing, uri)
74
+
75
+ seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name }
76
+ end
77
+
78
+ def upgradable?(existing, uri)
79
+ uri.scheme == "https" && existing[:origin].start_with?("http://")
80
+ end
81
+
82
+ # One backend being down, rate-limited, or misconfigured shouldn't take
83
+ # the whole search with it.
84
+ def urls_from(backend)
85
+ Array(backend.search(@query, limit: @limit))
86
+ rescue StandardError
87
+ []
88
+ end
89
+
90
+ def parse_http(url)
91
+ uri = URI.parse(url.to_s)
92
+ uri if uri.host && uri.scheme.to_s.start_with?("http")
93
+ rescue URI::InvalidURIError
94
+ nil
95
+ end
96
+
97
+ # Collapse every deep link a backend returns onto the origin, since
98
+ # that's the only thing `/.well-known/ucp` hangs off.
99
+ def origin_of(uri)
100
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
101
+ "#{uri.scheme}://#{uri.host}#{port}"
102
+ end
103
+
104
+ # --- Step 2: keep the ones that actually speak UCP ---
105
+
106
+ # A cached *miss* is the only verdict that saves work here — a cached hit
107
+ # still has to connect, because a live session is the thing we need next.
108
+ def probe(candidates)
109
+ probed = 0
110
+ candidates.filter_map do |candidate|
111
+ next if @cache.fetch(candidate[:origin]) == false
112
+
113
+ throttle(probed)
114
+ probed += 1
115
+ session = discover(candidate[:origin])
116
+ @cache.record(candidate[:origin], !session.nil?)
117
+ session && candidate.merge(session: session, checkout: checkout?(session))
118
+ end
119
+ end
120
+
121
+ def throttle(probed)
122
+ sleep(@throttle) if probed.positive? && @throttle.to_f.positive?
123
+ end
124
+
125
+ def discover(origin)
126
+ Portage::Ucp::Client.discover(origin)
127
+ rescue StandardError
128
+ nil
129
+ end
130
+
131
+ def checkout?(session)
132
+ session.advertises?(CART_CAP) && session.advertises?(CHECKOUT_CAP)
133
+ end
134
+
135
+ # --- Step 3: ask the survivors what they stock ---
136
+
137
+ def offers_for(store)
138
+ products = Array(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
139
+ products.filter_map { |product| offer(store, product) }
140
+ rescue StandardError
141
+ []
142
+ end
143
+
144
+ def offer(store, product)
145
+ amount, currency = price_of(product)
146
+ return nil if @max_price && amount && amount > @max_price
147
+
148
+ { store: store[:origin], source: store[:source], checkout: store[:checkout],
149
+ product_id: field(product, "id"), title: field(product, "title"),
150
+ amount: amount, currency: currency, url: field(product, "url") }
151
+ end
152
+
153
+ # Buyable first, then cheapest, then unpriced. Sorting on price alone
154
+ # would float a browse-only store above one you can actually check out
155
+ # from, which is the wrong answer to "buy me this".
156
+ def rank(offers)
157
+ offers.sort_by do |offer|
158
+ [offer[:checkout] ? 0 : 1, offer[:amount] ? 0 : 1, offer[:amount] || 0]
159
+ end
160
+ end
161
+
162
+ # --- Shapes ---
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.
168
+ def price_of(product)
169
+ range = field(product, "price_range")
170
+ return [money_amount(range["min"]), range["min"]["currency"]] if range.is_a?(Hash) && range["min"].is_a?(Hash)
171
+
172
+ scalar_price(field(product, "price"))
173
+ end
174
+
175
+ def scalar_price(price)
176
+ case price
177
+ when Hash then [money_amount(price), price["currency"]]
178
+ when Integer then [price, nil]
179
+ when nil then [nil, nil]
180
+ else [price.respond_to?(:amount_minor) ? price.amount_minor : nil,
181
+ price.respond_to?(:currency) ? price.currency : nil]
182
+ end
183
+ end
184
+
185
+ def money_amount(price) = price["amount"]
186
+
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
192
+
193
+ def report(**fields)
194
+ { query: @query, candidates: [], stores: [], offers: [], message: nil }.merge(fields)
195
+ end
196
+
197
+ def no_candidates_message
198
+ names = @backends.map(&:name)
199
+ return no_backends_message if names.empty?
200
+
201
+ "No candidate stores came back from #{names.join(', ')} for \"#{@query}\"."
202
+ end
203
+
204
+ def no_backends_message
205
+ "No search backend available — set BRAVE_SEARCH_API_KEY, GOOGLE_CSE_KEY/GOOGLE_CSE_CX, " \
206
+ "or list stores in ~/.portage/stores.yml."
207
+ end
208
+
209
+ def summary(candidates, stores, offers)
210
+ return "Found #{offers.length} offer(s) across #{stores.length} UCP store(s)." if offers.any?
211
+ return "#{stores.length} store(s) speak UCP but none stock \"#{@query}\"." if stores.any?
212
+
213
+ "Checked #{candidates.length} store(s); none of them speak UCP."
214
+ end
215
+ end
216
+ end
217
+ end
@@ -0,0 +1,64 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Remembers which origins answered `/.well-known/ucp` and which didn't.
7
+ #
8
+ # Without this, every URL-less search re-probes the same dozen hosts, and a
9
+ # tool that fans out one unsolicited request per host per invocation is a
10
+ # crawler wearing a CLI's clothes. Negative results are cached too — and
11
+ # for longer — because "this shop doesn't speak UCP" is the answer that
12
+ # would otherwise be re-asked most often and changes least.
13
+ class ProbeCache
14
+ PATH = File.join(Dir.home, ".portage", "discovery-cache.json").freeze
15
+ HIT_TTL = 6 * 60 * 60
16
+ MISS_TTL = 24 * 60 * 60
17
+
18
+ def initialize(path: PATH, now: Time.now)
19
+ @path = path
20
+ @now = now.to_i
21
+ end
22
+
23
+ # @return [Boolean, nil] cached verdict, or nil when unknown/expired.
24
+ def fetch(origin)
25
+ entry = store[origin]
26
+ return nil unless entry.is_a?(Hash) && entry.key?("ucp")
27
+
28
+ ttl = entry["ucp"] ? HIT_TTL : MISS_TTL
29
+ return nil if @now - entry["at"].to_i > ttl
30
+
31
+ entry["ucp"]
32
+ end
33
+
34
+ def record(origin, ucp)
35
+ store[origin] = { "ucp" => ucp, "at" => @now }
36
+ write
37
+ ucp
38
+ end
39
+
40
+ private
41
+
42
+ def store
43
+ @store ||= read
44
+ end
45
+
46
+ def read
47
+ return {} unless File.readable?(@path)
48
+
49
+ parsed = JSON.parse(File.read(@path))
50
+ parsed.is_a?(Hash) ? parsed : {}
51
+ rescue StandardError
52
+ {}
53
+ end
54
+
55
+ # A cache that can't be written is a slow cache, never a failed run.
56
+ def write
57
+ FileUtils.mkdir_p(File.dirname(@path))
58
+ File.write(@path, JSON.generate(@store))
59
+ rescue StandardError
60
+ nil
61
+ end
62
+ end
63
+ end
64
+ end
@@ -0,0 +1,201 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "yaml"
5
+
6
+ module Portage
7
+ module Cli
8
+ # Where "which stores might sell this?" comes from when the caller never
9
+ # named a store.
10
+ #
11
+ # Every backend here talks to a documented machine interface and returns
12
+ # bare candidate URLs. None of them parse a results page: scraping a search
13
+ # engine's HTML is the same class of ToS violation Buy already refuses to
14
+ # commit against a merchant, and it would be odd to be scrupulous about the
15
+ # shop and cavalier about the index. That rules out the usual
16
+ # `html.duckduckgo.com/html/?q=` trick — see DuckDuckGo below for what we
17
+ # use instead and what it costs us.
18
+ module SearchBackends
19
+ OPEN_TIMEOUT = 5
20
+ READ_TIMEOUT = 5
21
+ USER_AGENT = "portage-find".freeze
22
+
23
+ # Reference works and marketplaces-of-links that a search backend will
24
+ # happily return for a product query but that are never themselves a UCP
25
+ # store — cheap to skip, and each one skipped is one fewer host we probe.
26
+ NON_STORE_HOSTS = %w[
27
+ wikipedia.org wikimedia.org duckduckgo.com google.com bing.com
28
+ reddit.com youtube.com facebook.com x.com twitter.com pinterest.com
29
+ ].freeze
30
+
31
+ # Ordered cheapest/most-trusted first: your own allowlist costs no
32
+ # network call and needs no key, DuckDuckGo needs no key, the keyed
33
+ # engines only participate when their credentials are actually present.
34
+ def self.default
35
+ [Allowlist.new, DuckDuckGo.new, Brave.new, GoogleCse.new].select(&:available?)
36
+ end
37
+
38
+ def self.get_json(uri, params: {}, headers: {})
39
+ uri = uri.dup
40
+ uri.query = URI.encode_www_form(params) unless params.empty?
41
+ response = request(uri, headers)
42
+ response.is_a?(Net::HTTPSuccess) ? JSON.parse(response.body) : nil
43
+ rescue StandardError
44
+ nil
45
+ end
46
+
47
+ def self.request(uri, headers)
48
+ Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
49
+ open_timeout: OPEN_TIMEOUT, read_timeout: READ_TIMEOUT) do |http|
50
+ http.get(uri.request_uri, { "User-Agent" => USER_AGENT }.merge(headers))
51
+ end
52
+ end
53
+ private_class_method :request
54
+
55
+ # @return [Boolean] true when the URL is worth spending a manifest probe on.
56
+ def self.store_candidate?(url)
57
+ host = URI.parse(url.to_s).host
58
+ !!host && NON_STORE_HOSTS.none? { |bad| host == bad || host.end_with?(".#{bad}") }
59
+ rescue URI::InvalidURIError
60
+ false
61
+ end
62
+
63
+ # Stores you've already decided you trust, listed in
64
+ # `~/.portage/stores.yml` (a bare YAML array of URLs) or `PORTAGE_STORES`
65
+ # (comma-separated — the PATH-style colon can't separate values that
66
+ # contain `https://`). Query-independent on purpose: the point of the file
67
+ # is "always consider these", and the store's own catalog search is what
68
+ # decides whether it stocks the thing.
69
+ class Allowlist
70
+ PATH = File.join(Dir.home, ".portage", "stores.yml").freeze
71
+
72
+ def initialize(path: PATH, env: ENV.fetch("PORTAGE_STORES", nil))
73
+ @path = path
74
+ @env = env
75
+ end
76
+
77
+ def name = "allowlist"
78
+
79
+ def available? = !entries.empty?
80
+
81
+ def search(_query, limit: 10) = entries.first(limit)
82
+
83
+ private
84
+
85
+ def entries
86
+ @entries ||= (env_entries + file_entries).map { |e| e.to_s.strip }.reject(&:empty?).uniq
87
+ end
88
+
89
+ def env_entries = @env.to_s.split(",")
90
+
91
+ def file_entries
92
+ return [] unless File.readable?(@path)
93
+
94
+ Array(YAML.safe_load_file(@path))
95
+ rescue StandardError
96
+ []
97
+ end
98
+ end
99
+
100
+ # DuckDuckGo's Instant Answer API — official, documented, no key
101
+ # (https://api.duckduckgo.com/api).
102
+ #
103
+ # Know what it is before you lean on it: it answers *entity* queries, not
104
+ # web queries. "burton snowboards" resolves to burton.com through
105
+ # `Results`; "snowboard" resolves to nothing at all. So it covers "buy me
106
+ # a <brand> thing" well and open-ended shopping not at all. It's the
107
+ # keyless default because it's the only no-key engine with a real API;
108
+ # pair it with Brave or a Google CSE for actual breadth.
109
+ class DuckDuckGo
110
+ ENDPOINT = "https://api.duckduckgo.com/".freeze
111
+
112
+ def name = "duckduckgo"
113
+
114
+ def available? = true
115
+
116
+ def search(query, limit: 10)
117
+ data = SearchBackends.get_json(
118
+ URI.parse(ENDPOINT),
119
+ params: { q: query, format: "json", no_html: "1", no_redirect: "1", t: "portage" }
120
+ )
121
+ return [] unless data
122
+
123
+ urls(data).select { |u| SearchBackends.store_candidate?(u) }.uniq.first(limit)
124
+ end
125
+
126
+ private
127
+
128
+ # `Results` is the official-site answer and the only genuinely
129
+ # commercial field. `RelatedTopics` is mostly duckduckgo.com category
130
+ # links (filtered out downstream) but occasionally carries a real
131
+ # vendor, so it's worth flattening. `AbstractURL` is deliberately
132
+ # ignored — it's the encyclopedia entry, never the shop.
133
+ def urls(data)
134
+ direct = Array(data["Results"]).map { |r| r["FirstURL"] }
135
+ related = Array(data["RelatedTopics"]).flat_map { |topic| topic_urls(topic) }
136
+ (direct + related).compact
137
+ end
138
+
139
+ def topic_urls(topic)
140
+ return [] unless topic.is_a?(Hash)
141
+ return Array(topic["Topics"]).flat_map { |t| topic_urls(t) } if topic["Topics"]
142
+
143
+ [topic["FirstURL"]].compact
144
+ end
145
+ end
146
+
147
+ # Brave Search API (https://api-dashboard.search.brave.com) — real web
148
+ # results, needs BRAVE_SEARCH_API_KEY. This is the backend to set up if
149
+ # you want URL-less buying to work for generic queries.
150
+ class Brave
151
+ ENDPOINT = "https://api.search.brave.com/res/v1/web/search".freeze
152
+
153
+ def initialize(api_key: ENV.fetch("BRAVE_SEARCH_API_KEY", nil))
154
+ @api_key = api_key
155
+ end
156
+
157
+ def name = "brave"
158
+
159
+ def available? = !@api_key.to_s.empty?
160
+
161
+ def search(query, limit: 10)
162
+ data = SearchBackends.get_json(
163
+ URI.parse(ENDPOINT),
164
+ params: { q: query, count: limit },
165
+ headers: { "Accept" => "application/json", "X-Subscription-Token" => @api_key }
166
+ )
167
+ return [] unless data
168
+
169
+ Array(data.dig("web", "results")).map { |r| r["url"] }.compact
170
+ .select { |u| SearchBackends.store_candidate?(u) }.first(limit)
171
+ end
172
+ end
173
+
174
+ # Google Programmable Search (Custom Search JSON API) — needs
175
+ # GOOGLE_CSE_KEY and GOOGLE_CSE_CX. The documented API, not the SERP.
176
+ class GoogleCse
177
+ ENDPOINT = "https://www.googleapis.com/customsearch/v1".freeze
178
+
179
+ def initialize(api_key: ENV.fetch("GOOGLE_CSE_KEY", nil), cx: ENV.fetch("GOOGLE_CSE_CX", nil))
180
+ @api_key = api_key
181
+ @cx = cx
182
+ end
183
+
184
+ def name = "google_cse"
185
+
186
+ def available? = !@api_key.to_s.empty? && !@cx.to_s.empty?
187
+
188
+ def search(query, limit: 10)
189
+ data = SearchBackends.get_json(
190
+ URI.parse(ENDPOINT),
191
+ params: { key: @api_key, cx: @cx, q: query, num: [limit, 10].min }
192
+ )
193
+ return [] unless data
194
+
195
+ Array(data["items"]).map { |i| i["link"] }.compact
196
+ .select { |u| SearchBackends.store_candidate?(u) }.first(limit)
197
+ end
198
+ end
199
+ end
200
+ end
201
+ end
@@ -0,0 +1,5 @@
1
+ module Portage
2
+ module Cli
3
+ VERSION = "0.1.0".freeze
4
+ end
5
+ end
@@ -0,0 +1,202 @@
1
+ require "optparse"
2
+ require "json"
3
+
4
+ require_relative "cli/version"
5
+ require_relative "cli/buy"
6
+ require_relative "cli/find"
7
+
8
+ module Portage
9
+ # `portage` — the single command-line entrypoint for acting as a shopper's
10
+ # agent against any store, native-UCP or not. See Cli::Buy for the buying
11
+ # algorithm and Cli::Find for the "I don't have a URL" search that feeds it;
12
+ # this module is just argument parsing + subcommand dispatch.
13
+ module Cli
14
+ USAGE = <<~USAGE.freeze
15
+ usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
16
+ [--product-id ID] [--yes] [--dry-run] [--json]
17
+ portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
18
+ portage find --query "..." [--max-price N] [--limit N] [--json]
19
+ USAGE
20
+
21
+ # @param argv [Array<String>]
22
+ # @return [Integer] process exit code
23
+ def self.run(argv)
24
+ command, *rest = argv
25
+ case command
26
+ when "buy" then run_buy(rest)
27
+ when "find" then run_find(rest)
28
+ else
29
+ warn USAGE
30
+ 1
31
+ end
32
+ end
33
+
34
+ # --- find ---
35
+
36
+ def self.run_find(argv)
37
+ options = parse_find_options(argv)
38
+ return 1 unless options
39
+
40
+ json = options.delete(:json)
41
+ report = Find.new(**options).call
42
+ puts json ? JSON.pretty_generate(report) : format_find(report)
43
+ report[:offers].any? ? 0 : 1
44
+ end
45
+ private_class_method :run_find
46
+
47
+ def self.parse_find_options(argv)
48
+ opts = {}
49
+ find_option_parser(opts).parse!(argv)
50
+ if opts[:query].to_s.strip.empty?
51
+ warn USAGE
52
+ return nil
53
+ end
54
+
55
+ opts
56
+ end
57
+ private_class_method :parse_find_options
58
+
59
+ def self.find_option_parser(opts)
60
+ OptionParser.new do |parser|
61
+ parser.on("--query QUERY") { |v| opts[:query] = v }
62
+ parser.on("--limit N", Integer) { |v| opts[:limit] = v }
63
+ parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
64
+ parser.on("--json") { opts[:json] = true }
65
+ end
66
+ end
67
+ private_class_method :find_option_parser
68
+
69
+ # `--max-price 400` means 400 of whatever the offer is priced in, and the
70
+ # comparison happens per-offer in that offer's own currency — no FX
71
+ # conversion, and no attempt to handle zero-decimal currencies like JPY.
72
+ def self.to_minor_units(major) = (major * 100).round
73
+ private_class_method :to_minor_units
74
+
75
+ # --- buy ---
76
+
77
+ def self.run_buy(argv)
78
+ parsed = parse_buy_options(argv)
79
+ return 1 unless parsed
80
+
81
+ url = parsed[:buy][:url] || parsed[:store]
82
+ return execute_buy(parsed, url) if url
83
+
84
+ buy_from_search(parsed)
85
+ end
86
+ private_class_method :run_buy
87
+
88
+ # `portage buy` with no URL: search first, then buy from the store the
89
+ # caller picks. `--yes` alone deliberately isn't enough to get here —
90
+ # without a URL the merchant would have been chosen by a search ranker
91
+ # rather than by a person, so either `--store` (handled above) or an
92
+ # interactive pick has to name it. Piped/CI runs list the offers and stop.
93
+ def self.buy_from_search(parsed)
94
+ report = Find.new(**parsed[:find]).call
95
+ offer = pick_offer(report, parsed[:json])
96
+ return report[:offers].any? ? 0 : 1 unless offer
97
+
98
+ execute_buy(parsed, offer[:store], product_id: offer[:product_id])
99
+ end
100
+ private_class_method :buy_from_search
101
+
102
+ def self.pick_offer(report, json)
103
+ output = json ? JSON.pretty_generate(report) : format_find(report)
104
+ puts output
105
+ return nil unless $stdin.tty? && report[:offers].any?
106
+
107
+ prompt_for_offer(report[:offers])
108
+ end
109
+ private_class_method :pick_offer
110
+
111
+ def self.prompt_for_offer(offers)
112
+ print "\nPick 1-#{offers.length} to buy (Enter to quit): "
113
+ choice = $stdin.gets.to_s.strip
114
+ return nil unless choice.match?(/\A\d+\z/)
115
+
116
+ offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
117
+ end
118
+ private_class_method :prompt_for_offer
119
+
120
+ def self.execute_buy(parsed, url, product_id: nil)
121
+ options = parsed[:buy].merge(url: url)
122
+ options[:product_id] ||= product_id
123
+ report = Buy.new(**options).call
124
+ puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
125
+ report[:checkout] || report[:browse] ? 0 : 1
126
+ end
127
+ private_class_method :execute_buy
128
+
129
+ def self.parse_buy_options(argv)
130
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
131
+ buy = { url: url, qty: 1, yes: false, dry_run: false }
132
+ parsed = { buy: buy, find: {} }
133
+ buy_option_parser(buy, parsed).parse!(argv)
134
+ buy[:query] ||= ""
135
+ return parsed if url || !buy[:query].strip.empty?
136
+
137
+ warn USAGE
138
+ nil
139
+ end
140
+ private_class_method :parse_buy_options
141
+
142
+ def self.buy_option_parser(buy, parsed)
143
+ OptionParser.new do |parser|
144
+ parser.on("--qty N", Integer) { |v| buy[:qty] = v }
145
+ parser.on("--payment-token TOKEN") { |v| buy[:payment_token] = v }
146
+ parser.on("--product-id ID") { |v| buy[:product_id] = v }
147
+ parser.on("--yes") { buy[:yes] = true }
148
+ parser.on("--dry-run") { buy[:dry_run] = true }
149
+ parser.on("--json") { parsed[:json] = true }
150
+ add_search_options(parser, buy, parsed)
151
+ end
152
+ end
153
+ private_class_method :buy_option_parser
154
+
155
+ # `--query` feeds both halves: it's the store search when there's no URL
156
+ # and the catalog search once a store is settled, so it's registered once
157
+ # here rather than twice on the same parser.
158
+ def self.add_search_options(parser, buy, parsed)
159
+ parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
160
+ parser.on("--store URL") { |v| parsed[:store] = v }
161
+ parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
162
+ parser.on("--max-price N", Float) { |v| parsed[:find][:max_price] = to_minor_units(v) }
163
+ end
164
+ private_class_method :add_search_options
165
+
166
+ # --- output ---
167
+
168
+ def self.format_report(report)
169
+ lines = ["#{report[:message]} (source: #{report[:source]})"]
170
+ report[:products].each { |p| lines << " - #{product_line(p)}" }
171
+ lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
172
+ lines.join("\n")
173
+ end
174
+ private_class_method :format_report
175
+
176
+ def self.product_line(product)
177
+ product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
178
+ end
179
+ private_class_method :product_line
180
+
181
+ def self.format_find(report)
182
+ lines = [report[:message].to_s]
183
+ report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
184
+ lines.join("\n")
185
+ end
186
+ private_class_method :format_find
187
+
188
+ def self.offer_line(offer)
189
+ parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
190
+ parts << "browse only" unless offer[:checkout]
191
+ parts.join(" — ")
192
+ end
193
+ private_class_method :offer_line
194
+
195
+ def self.format_price(offer)
196
+ return "price n/a" unless offer[:amount]
197
+
198
+ "#{format('%.2f', offer[:amount] / 100.0)}#{" #{offer[:currency]}" if offer[:currency]}"
199
+ end
200
+ private_class_method :format_price
201
+ end
202
+ end
metadata ADDED
@@ -0,0 +1,149 @@
1
+ --- !ruby/object:Gem::Specification
2
+ name: portage-cli
3
+ version: !ruby/object:Gem::Version
4
+ version: 0.1.0
5
+ platform: ruby
6
+ authors:
7
+ - Tom Whitbread
8
+ autorequire:
9
+ bindir: exe
10
+ cert_chain: []
11
+ date: 2026-08-14 00:00:00.000000000 Z
12
+ dependencies:
13
+ - !ruby/object:Gem::Dependency
14
+ name: portage-ucp
15
+ requirement: !ruby/object:Gem::Requirement
16
+ requirements:
17
+ - - "~>"
18
+ - !ruby/object:Gem::Version
19
+ version: '0.1'
20
+ type: :runtime
21
+ prerelease: false
22
+ version_requirements: !ruby/object:Gem::Requirement
23
+ requirements:
24
+ - - "~>"
25
+ - !ruby/object:Gem::Version
26
+ version: '0.1'
27
+ - !ruby/object:Gem::Dependency
28
+ name: portage-ucp-client
29
+ requirement: !ruby/object:Gem::Requirement
30
+ requirements:
31
+ - - "~>"
32
+ - !ruby/object:Gem::Version
33
+ version: '0.1'
34
+ type: :runtime
35
+ prerelease: false
36
+ version_requirements: !ruby/object:Gem::Requirement
37
+ requirements:
38
+ - - "~>"
39
+ - !ruby/object:Gem::Version
40
+ version: '0.1'
41
+ - !ruby/object:Gem::Dependency
42
+ name: rspec
43
+ requirement: !ruby/object:Gem::Requirement
44
+ requirements:
45
+ - - "~>"
46
+ - !ruby/object:Gem::Version
47
+ version: '3.13'
48
+ type: :development
49
+ prerelease: false
50
+ version_requirements: !ruby/object:Gem::Requirement
51
+ requirements:
52
+ - - "~>"
53
+ - !ruby/object:Gem::Version
54
+ version: '3.13'
55
+ - !ruby/object:Gem::Dependency
56
+ name: rubocop
57
+ requirement: !ruby/object:Gem::Requirement
58
+ requirements:
59
+ - - "~>"
60
+ - !ruby/object:Gem::Version
61
+ version: '1.88'
62
+ type: :development
63
+ prerelease: false
64
+ version_requirements: !ruby/object:Gem::Requirement
65
+ requirements:
66
+ - - "~>"
67
+ - !ruby/object:Gem::Version
68
+ version: '1.88'
69
+ - !ruby/object:Gem::Dependency
70
+ name: webmock
71
+ requirement: !ruby/object:Gem::Requirement
72
+ requirements:
73
+ - - "~>"
74
+ - !ruby/object:Gem::Version
75
+ version: '3.24'
76
+ type: :development
77
+ prerelease: false
78
+ version_requirements: !ruby/object:Gem::Requirement
79
+ requirements:
80
+ - - "~>"
81
+ - !ruby/object:Gem::Version
82
+ version: '3.24'
83
+ - !ruby/object:Gem::Dependency
84
+ name: yard
85
+ requirement: !ruby/object:Gem::Requirement
86
+ requirements:
87
+ - - "~>"
88
+ - !ruby/object:Gem::Version
89
+ version: '0.9'
90
+ type: :development
91
+ prerelease: false
92
+ version_requirements: !ruby/object:Gem::Requirement
93
+ requirements:
94
+ - - "~>"
95
+ - !ruby/object:Gem::Version
96
+ version: '0.9'
97
+ description: 'Ships the `portage` executable. `portage buy <url>` tries native UCP
98
+ discovery first (zero credentials, works on any store that''s opted in), falls back
99
+ to a portage-ucp-* platform adapter only when this process already has that platform''s
100
+ own credentials in env (i.e. it''s your own store or one you''re integrated with),
101
+ and otherwise says so plainly — never scrapes or session-hijacks as an anonymous
102
+ shopper. `portage find` covers the no-URL case: ask an allowlist, DuckDuckGo, Brave,
103
+ or a Google Programmable Search engine which stores might sell something, keep the
104
+ ones answering /.well-known/ucp, and search their catalogs — documented APIs only,
105
+ no SERP scraping. Depends on portage-ucp (for platform detection via Resolver) and
106
+ portage-ucp-client (for the actual buy calls); no single adapter gem is a hard dependency.'
107
+ email:
108
+ executables:
109
+ - portage
110
+ extensions: []
111
+ extra_rdoc_files: []
112
+ files:
113
+ - CHANGELOG.md
114
+ - LICENSE
115
+ - README.md
116
+ - exe/portage
117
+ - lib/portage/cli.rb
118
+ - lib/portage/cli/buy.rb
119
+ - lib/portage/cli/find.rb
120
+ - lib/portage/cli/probe_cache.rb
121
+ - lib/portage/cli/search_backends.rb
122
+ - lib/portage/cli/version.rb
123
+ homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
124
+ licenses:
125
+ - MIT
126
+ metadata:
127
+ source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
128
+ changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
129
+ rubygems_mfa_required: 'true'
130
+ post_install_message:
131
+ rdoc_options: []
132
+ require_paths:
133
+ - lib
134
+ required_ruby_version: !ruby/object:Gem::Requirement
135
+ requirements:
136
+ - - ">="
137
+ - !ruby/object:Gem::Version
138
+ version: '3.2'
139
+ required_rubygems_version: !ruby/object:Gem::Requirement
140
+ requirements:
141
+ - - ">="
142
+ - !ruby/object:Gem::Version
143
+ version: '0'
144
+ requirements: []
145
+ rubygems_version: 3.5.22
146
+ signing_key:
147
+ specification_version: 4
148
+ summary: portage — one CLI command to buy from any store, native UCP or not
149
+ test_files: []