portage-cli 0.4.1 → 0.6.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: 86b0359cf6f3c98f45853a7407dd03e9688f91212ce1ee05e84727b34450e3ad
4
- data.tar.gz: 94eeec1e819b534d38055bc55c61a74cc61730d0134e5f7bc5da7aef3a257485
3
+ metadata.gz: 4acb63ece20e24b7d43048315145a96e15ded1c4e5b0cf83c3f045603a033af6
4
+ data.tar.gz: 536057c80badcd44ee1be90679fd7f9233adb3d5a1c07b5b11a12201fbae5701
5
5
  SHA512:
6
- metadata.gz: b8f1f2b0c0a9addba9154c3951c90ec4c3dcd1b8b765e03eca8c13ed17f80c23b7999efaa993c4173f045f12c7ad724705667e00164d5f3a87905caab216197a
7
- data.tar.gz: 26e138751551e630c86de1ccd2e701f9cf287ef0cf6ed78e852bae7ed8c43922257d3f7a3840a3f8735de0306c4d39e01f40bf9348388041a9516c65bd3dbbd8
6
+ metadata.gz: 54bc26bf74fe96c2a2154062ab41619e10bf046da1cc06f067fc1f0f7eaec9c29ff6ab461e9cf2907ede4084805b8718f65557e5d4c3c79c486650eea10c1075
7
+ data.tar.gz: deb45f093ce769ebae837bad5af628c8970aac76b6741ea8b083393b45038451a455ece2d603671c3d2884e3974858941ff6c29d96affd7bd3bbcc89f6a689d9
data/CHANGELOG.md CHANGED
@@ -4,7 +4,72 @@ 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
- ## [Unreleased]
7
+ ## [0.6.0] - 2026-09-17
8
+
9
+ - Fixed `buy`/`find` crashing with a raw `Faraday::UnprocessableContentError`
10
+ against a real UCP store, once manifest parsing succeeded — the actual
11
+ `search_catalog`/`create_checkout` calls were still built in the wrong
12
+ wire shape (see `portage-ucp-client` 0.4.0). Both commands
13
+ now report a clear, actionable message instead: set `PORTAGE_AGENT_PROFILE`
14
+ when it's missing, or surface the store's rejection cleanly when it's set
15
+ but not accepted.
16
+ - Added `PORTAGE_AGENT_PROFILE` — required for `find`/`buy` against a real,
17
+ external UCP store (not your own store via an adapter). No default; see
18
+ `.env.example`.
19
+ - Fixed `buy` sending a catalog product's own id as the purchasable line
20
+ item — correct for a backend where "the product" and "the thing you add
21
+ to a cart" share one id, but wrong for Shopify (confirmed live: every
22
+ `portage buy` against a real Shopify test store failed with "Invalid id",
23
+ since Storefront's cart takes a `ProductVariant` GID, not the parent
24
+ `Product` GID `search_catalog` returns as `id`). `Buy#full_buy` and
25
+ `#redirect_checkout` now build `line_items` from a product's first/
26
+ default variant id when one exists, falling back to the product id
27
+ otherwise; `--product-id` matching (`#product_id_of`) is unchanged, since
28
+ it still needs to match the catalog-level id `find`/`compare` show the
29
+ caller.
30
+ - Added `portage generate agent-profile`, which writes a real UCP
31
+ agent-identity document (the JSON a store fetches from the
32
+ `meta.ucp-agent.profile` URL to decide whether to answer at all).
33
+ `portage-cli`'s own generated profile is checked in at
34
+ `agent-profile/agent-profile.json` and published to a stable URL by
35
+ `.github/workflows/publish-agent-profile.yml`, which is what
36
+ `PORTAGE_AGENT_PROFILE` defaults to pointing at.
37
+ - Widens the `portage-ucp` pin to `~> 0.8` and the `portage-ucp-client` pin
38
+ to `~> 0.4` so this gem installs alongside the 0.8.0-line releases.
39
+
40
+ ## [0.5.1] - 2026-09-16
41
+
42
+ - No behavior change — 0.5.0 was built and pushed with `gem build` run from
43
+ the workspace root instead of this gem's own directory, so `spec.files =
44
+ Dir[...]` resolved against the wrong working directory and packaged an
45
+ empty gem. 0.5.0 has been yanked; 0.5.1 repackages the exact same 0.5.0
46
+ code correctly.
47
+
48
+ ## [0.5.0] - 2026-09-16
49
+
50
+ - Added `portage-console` — a read-only IRB REPL over the local
51
+ `TransactionLog`/`OrderLedger`/`PurchaseJournal` stores (design-log §22
52
+ item 6, `Portage::Cli::Console`). `transactions`/`find_transaction`/
53
+ `transactions_since`, `orders`/`find_order`, and `journal`. Every result
54
+ passes through `Portage::Ucp::Observability.redact`. Deliberately a local
55
+ REPL, not the admin/web panel design-log §16 also describes — see the
56
+ README's Console section for why. New runtime dependency on
57
+ `portage-ucp-journal` (`~> 0.1`).
58
+ - `Buy#client_for` now passes a real `Portage::Ucp::Journal::PurchaseJournal`
59
+ (file-backed, `~/.portage/journal.jsonl`) into `Client.for_adapter` — the
60
+ own-store loopback path used by `portage buy` against your own store now
61
+ actually records to the purchase journal, closing the gap `portage-console`
62
+ above depends on: `journal` was empty on this path because nothing passed
63
+ `journal:` through it (design-log §37/§38).
64
+ - Added `portage generate adapter` — scaffolds a new adapter gem (gemspec,
65
+ Gemfile, Rakefile, rubocop config, lib entrypoint, version file, an
66
+ `Adapter` subclass with every capability method stubbed by reflecting off
67
+ `Portage::Ucp::Adapter`'s real method signatures, and a conformance spec),
68
+ modeled on `portage-ucp-etsy`.
69
+ - Added `portage doctor` — sanity-checks a seller's
70
+ `Portage::Ucp.configuration` (authenticator/rate-limiter left at
71
+ unconfigured fail-safe defaults, no signing keys/payment handlers
72
+ configured, adapter capabilities only half-implemented).
8
73
 
9
74
  ## [0.4.1] - 2026-09-15
10
75
 
data/README.md CHANGED
@@ -39,11 +39,13 @@ portage compare https://your-shop.example --product-id prod_123 --results 5
39
39
  ```
40
40
 
41
41
  Depends on [`portage-ucp`](https://github.com/tomtom87/Portage/tree/main/portage-ucp)
42
- (for platform detection via `Resolver`) and
42
+ (for platform detection via `Resolver`),
43
43
  [`portage-ucp-client`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-client)
44
- (for the actual buy calls). No single adapter gem is a hard dependency — install
45
- whichever `portage-ucp-<platform>` gem matches the store you're integrated with,
46
- if any.
44
+ (for the actual buy calls), and
45
+ [`portage-ucp-journal`](https://github.com/tomtom87/Portage/tree/main/portage-ucp-journal)
46
+ (for `portage-console`'s read-only view of local purchase state — see below).
47
+ No single adapter gem is a hard dependency — install whichever
48
+ `portage-ucp-<platform>` gem matches the store you're integrated with, if any.
47
49
 
48
50
  ## Installation
49
51
 
@@ -313,6 +315,39 @@ Backends that have no credentials sit out; DuckDuckGo is the keyless default
313
315
  because it's the only no-key engine with a real API, and its narrowness is the
314
316
  price of not scraping.
315
317
 
318
+ ### Console
319
+
320
+ `portage-console` is a separate executable — a read-only IRB REPL over the
321
+ three local `~/.portage` stores (design-log §22 item 6): `TransactionLog`
322
+ (reserve/complete records), `OrderLedger` (settled-order snapshots), and, if
323
+ you've wired `journal:` into your own `Dispatcher` (nothing in this gem does
324
+ that for you), the `portage-ucp-journal` gem's `PurchaseJournal`.
325
+
326
+ ```bash
327
+ portage-console
328
+ ```
329
+
330
+ ```ruby
331
+ transactions # every reserved/completed transaction
332
+ transactions(shop: "your-shop.example")
333
+ find_transaction("idem_key_123")
334
+ transactions_since(Time.now - 86400, shop: "your-shop.example")
335
+
336
+ orders # every settled-order snapshot
337
+ find_order("order_123")
338
+
339
+ journal # empty unless a Dispatcher was built with journal:
340
+ ```
341
+
342
+ Every result is passed through `Portage::Ucp::Observability.redact` before
343
+ it's returned — `payment_token`/`oauth_token`/`Authorization` and the PII
344
+ fields on an order's fulfillment destinations never print, even in a REPL you
345
+ trust. This is deliberately a local REPL, not the admin/web panel design-log
346
+ §16 also describes: a browser is a new place for a token to leak, and the
347
+ process holding a web panel also holds this machine's live platform admin
348
+ credentials in its env — problems a REPL run by whoever already has shell
349
+ access to this machine doesn't have.
350
+
316
351
  ## Development
317
352
 
318
353
  ```bash
@@ -0,0 +1,11 @@
1
+ #!/usr/bin/env ruby
2
+ # frozen_string_literal: true
3
+
4
+ require "irb"
5
+ require "portage/cli/console"
6
+
7
+ include Portage::Cli::Console # rubocop:disable Style/MixinUsage -- top-level REPL helpers, by design
8
+
9
+ puts "portage-console — read-only helpers: transactions, find_transaction(key), " \
10
+ "transactions_since(time, shop:), orders, find_order(id), journal. Every result is redacted."
11
+ IRB.start
@@ -3,6 +3,7 @@ require "uri"
3
3
  require "json"
4
4
  require "portage/ucp"
5
5
  require "portage/ucp/client"
6
+ require "portage/ucp/journal"
6
7
  require_relative "payment_methods"
7
8
 
8
9
  module Portage
@@ -60,6 +61,14 @@ module Portage
60
61
 
61
62
  def discover(url)
62
63
  Portage::Ucp::Client.discover(url.to_s)
64
+ rescue Portage::Ucp::Client::ManifestShapeError => e
65
+ # The store *is* running UCP — this client just couldn't parse its
66
+ # manifest. Distinct from a genuine 404/unreachable host below:
67
+ # falling through silently there would hide a bug in this gem behind
68
+ # the same "no automated path" message a store with no UCP support
69
+ # gets, so this warns instead.
70
+ warn "portage: #{url} serves a UCP manifest this client couldn't parse (#{e.message})"
71
+ nil
63
72
  rescue Portage::Ucp::Client::DiscoveryError
64
73
  nil
65
74
  end
@@ -70,6 +79,30 @@ module Portage
70
79
  else
71
80
  catalog_only(session)
72
81
  end
82
+ rescue Portage::Ucp::Client::MissingAgentProfileError, Portage::Ucp::Client::UnsupportedWireShapeError,
83
+ MCP::Client::RequestHandlerError => e
84
+ native_flow_error_report(e)
85
+ end
86
+
87
+ def native_flow_error_report(error)
88
+ case error
89
+ when Portage::Ucp::Client::MissingAgentProfileError
90
+ build_report(source: "native_ucp", browse: false, checkout: false,
91
+ message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
92
+ "#{@uri} verifies it before answering any UCP call.")
93
+ when Portage::Ucp::Client::UnsupportedWireShapeError
94
+ build_report(source: "native_ucp", browse: true, checkout: false,
95
+ message: "Can't complete checkout on #{@uri} yet: #{error.message}")
96
+ else
97
+ # MCP::Client::RequestHandlerError doesn't retain the server's JSON
98
+ # error body on this path, so this can't quote the server's own
99
+ # explanation — a common cause is PORTAGE_AGENT_PROFILE not
100
+ # pointing at a real, JSON agent-profile document the store's UCP
101
+ # endpoint accepts.
102
+ build_report(source: "native_ucp", browse: false, checkout: false,
103
+ message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
104
+ "is set, check it points at a real agent-profile document the store accepts.")
105
+ end
73
106
  end
74
107
 
75
108
  def catalog_only(session)
@@ -127,8 +160,18 @@ module Portage
127
160
  Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
128
161
  end
129
162
 
163
+ # `journal:` closes design-log §37's named gap — `Mcp::Server.build`
164
+ # (and the `Loopback`/`Client.for_adapter` it sits behind) now forward
165
+ # a `journal:` kwarg straight to `Dispatcher`, but nothing on this own-
166
+ # store loopback path ever passed one, so `portage buy` against your
167
+ # own store settled real transactions/orders while leaving the journal
168
+ # `nil`. `PurchaseJournal.new`'s own default (`FileStore.new`) is
169
+ # `~/.portage/journal.jsonl` — the same "real file-backed default"
170
+ # posture `transaction_log:`/`order_ledger:` already get from
171
+ # `Dispatcher#initialize`.
130
172
  def client_for(adapter)
131
- Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new)
173
+ Portage::Ucp::Client.for_adapter(adapter, authenticator: PermissiveAuthenticator.new,
174
+ journal: Portage::Ucp::Journal::PurchaseJournal.new)
132
175
  end
133
176
 
134
177
  def catalog_only_adapter(adapter, platform)
@@ -145,8 +188,9 @@ module Portage
145
188
  def redirect_checkout(adapter, products)
146
189
  return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
147
190
 
148
- adapter.create_checkout(line_items: [{ product_id: products.first.id, quantity: @qty }],
149
- idempotency_key: "portage-buy-#{products.first.id}")
191
+ item_id = products.first.variants&.first&.id || products.first.id
192
+ adapter.create_checkout(line_items: [{ product_id: item_id, quantity: @qty }],
193
+ idempotency_key: "portage-buy-#{item_id}")
150
194
  rescue StandardError
151
195
  nil
152
196
  end
@@ -167,8 +211,9 @@ module Portage
167
211
  message: no_match_message)
168
212
  end
169
213
 
170
- checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
171
- fulfillment: requested_fulfillment(fulfillment_adapter))
214
+ checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
215
+ fulfillment: requested_fulfillment(fulfillment_adapter),
216
+ meta: agent_meta)
172
217
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
173
218
  finish_checkout(session, source, products, checkout)
174
219
  end
@@ -253,6 +298,20 @@ module Portage
253
298
  product["id"]
254
299
  end
255
300
 
301
+ # `create_checkout`'s `line_items[].product_id` is the conformance
302
+ # kit's overloaded name (portage-ucp/lib/portage/ucp/rspec.rb) for
303
+ # "whatever id this adapter's cart actually takes" — for a backend
304
+ # where a product's variants have their own id (confirmed live on
305
+ # Shopify: a ProductVariant GID, distinct from the parent Product
306
+ # GID), that's the first/default variant, not the catalog id
307
+ # #product_id_of returns for display/--product-id matching. A
308
+ # product with no variants (or a backend that doesn't distinguish
309
+ # the two) falls back to the product id unchanged (see
310
+ # docs/design-log.md §41).
311
+ def line_item_id_of(product)
312
+ product["variants"]&.first&.dig("id") || product_id_of(product)
313
+ end
314
+
256
315
  def finish_checkout(session, source, products, checkout)
257
316
  status = checkout["status"]
258
317
  return escalation_report(source, products, checkout) if status == "requires_escalation"
@@ -303,7 +362,15 @@ module Portage
303
362
  end
304
363
 
305
364
  def safe_search(session)
306
- CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
365
+ CatalogProducts.from(session.search_catalog(query: @query, limit: 10, meta: agent_meta))
366
+ end
367
+
368
+ # Real UCP servers fetch this URL to verify the caller's identity
369
+ # before answering any call (see Transports::Http) — the own-store
370
+ # loopback path ignores it harmlessly, so it's cheapest to always pass
371
+ # it rather than branch on which transport `session` happens to be.
372
+ def agent_meta
373
+ { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
307
374
  end
308
375
 
309
376
  # --- Homepage fetch (used by both the manifest-not-found path and the
@@ -0,0 +1,68 @@
1
+ require "portage/ucp"
2
+ require "portage/ucp/journal"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Read-only REPL helpers over the three local stores design-log §22
7
+ # names (`TransactionLog`, `OrderLedger`, `PurchaseJournal`) — the
8
+ # console remainder of item 6. Deliberately a REPL, not the web/admin
9
+ # panel §16 also described: that panel needs its own session auth
10
+ # because a browser is a new place for a token to leak, on a process
11
+ # that already holds live platform admin credentials in its env. A
12
+ # local REPL run by whoever already holds this machine's `~/.portage`
13
+ # files and env has neither problem, so this is `exe/portage-console`
14
+ # + IRB rather than a server. Every returned record still goes through
15
+ # `Observability.redact` regardless — no new leak surface isn't the
16
+ # same guarantee as never printing a secret.
17
+ #
18
+ # Each store defaults to its own real `~/.portage/*` file, injectable
19
+ # (same posture as `Dispatcher`'s own `transaction_log:`/`order_ledger:`/
20
+ # `journal:` kwargs) so a spec, or a REPL session pointed at a different
21
+ # machine's exported files, doesn't have to touch the real ones.
22
+ #
23
+ # `journal` reads `~/.portage/journal.jsonl` and comes back empty unless
24
+ # whatever built the `Dispatcher` for your purchases passed a `journal:`
25
+ # (core's own default is `nil`) — `transactions`/`orders` don't have
26
+ # that caveat, since `TransactionLog`/`OrderLedger` are Dispatcher's
27
+ # un-opt-out-able defaults.
28
+ #
29
+ # @example
30
+ # require "portage/cli/console"
31
+ # include Portage::Cli::Console
32
+ # transactions(shop: "example.myshopify.com")
33
+ module Console
34
+ def transactions(shop: nil, transaction_log: Portage::Ucp::Support::TransactionLog.new)
35
+ scope(transaction_log.all, shop: shop)
36
+ end
37
+
38
+ def find_transaction(idempotency_key, transaction_log: Portage::Ucp::Support::TransactionLog.new)
39
+ redact(transaction_log.find(idempotency_key))
40
+ end
41
+
42
+ def transactions_since(since, shop:, transaction_log: Portage::Ucp::Support::TransactionLog.new)
43
+ redact(transaction_log.completed_since(since, shop: shop))
44
+ end
45
+
46
+ def orders(order_ledger: Portage::Ucp::Support::OrderLedger.new)
47
+ scope(order_ledger.all)
48
+ end
49
+
50
+ def find_order(order_id, order_ledger: Portage::Ucp::Support::OrderLedger.new)
51
+ redact(order_ledger.find(order_id))
52
+ end
53
+
54
+ def journal(shop: nil, purchase_journal: Portage::Ucp::Journal::PurchaseJournal.new)
55
+ scope(purchase_journal.all, shop: shop)
56
+ end
57
+
58
+ private
59
+
60
+ def scope(records, shop: nil)
61
+ records = records.select { |record| record["shop"] == shop } if shop
62
+ redact(records)
63
+ end
64
+
65
+ def redact(records) = Portage::Ucp::Observability.redact(records)
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,91 @@
1
+ require "portage/ucp"
2
+
3
+ module Portage
4
+ module Cli
5
+ # `portage doctor` — sanity-checks a seller's Portage::Ucp setup for the
6
+ # footguns nothing else catches automatically: an authenticator/rate
7
+ # limiter left at their fail-safe default (Configuration#initialize),
8
+ # no signing keys or payment handlers configured, and (when an adapter
9
+ # class is given) a capability that's only half-implemented — one action
10
+ # overridden, its siblings still raising Adapter's NotImplementedError.
11
+ # Runs against whatever `Portage::Ucp.configuration` looks like in this
12
+ # process, so a host app passes `--require` to load its own initializer
13
+ # first (Rails: `--require ./config/environment`).
14
+ class Doctor
15
+ Finding = Struct.new(:check, :message, keyword_init: true)
16
+
17
+ def initialize(adapter_class: nil)
18
+ @adapter_class = adapter_class
19
+ end
20
+
21
+ def call
22
+ [
23
+ authenticator_finding,
24
+ rate_limiter_finding,
25
+ signing_keys_finding,
26
+ payment_handlers_finding,
27
+ *capability_findings
28
+ ].compact
29
+ end
30
+
31
+ private
32
+
33
+ def config = Portage::Ucp.configuration
34
+
35
+ def authenticator_finding
36
+ return unless config.authenticator.is_a?(Portage::Ucp::UnconfiguredAuthenticator)
37
+
38
+ Finding.new(check: "authenticator",
39
+ message: "UnconfiguredAuthenticator is still in place — every mutating capability " \
40
+ "call is rejected. Set config.authenticator.")
41
+ end
42
+
43
+ def rate_limiter_finding
44
+ return unless config.rate_limiter.is_a?(Portage::Ucp::NullRateLimiter)
45
+
46
+ Finding.new(check: "rate_limiter",
47
+ message: "NullRateLimiter is still in place — no rate limiting is applied. " \
48
+ "Set config.rate_limiter if that's not intentional.")
49
+ end
50
+
51
+ def signing_keys_finding
52
+ return unless Array(config.signing_keys).empty?
53
+
54
+ Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
55
+ end
56
+
57
+ def payment_handlers_finding
58
+ return unless Array(config.payment_handlers).empty?
59
+
60
+ Finding.new(check: "payment_handlers",
61
+ message: "No payment_handlers configured — buyers can't complete a " \
62
+ "payment-enrolled checkout.")
63
+ end
64
+
65
+ # Only action-based capabilities (predicate-based ones like discount/
66
+ # fulfillment need a live instance to call their predicate method, and
67
+ # doctor never instantiates an arbitrary adapter — it may need real
68
+ # credentials to construct).
69
+ def capability_findings
70
+ return [] unless @adapter_class
71
+
72
+ Portage::Ucp::Capabilities::ALL.filter_map { |capability| partial_capability_finding(capability) }
73
+ end
74
+
75
+ def partial_capability_finding(capability)
76
+ return if capability.actions.nil? || capability.actions.empty?
77
+
78
+ overridden = capability.actions.values.select { |method_name| overridden?(method_name) }
79
+ return if overridden.empty? || overridden.length == capability.actions.length
80
+
81
+ Finding.new(check: "capability:#{capability.name}",
82
+ message: "#{capability.name} implements #{overridden.length}/#{capability.actions.length} " \
83
+ "actions (#{overridden.join(', ')}) — the rest still raise NotImplementedError.")
84
+ end
85
+
86
+ def overridden?(method_name)
87
+ @adapter_class.instance_method(method_name).owner != Portage::Ucp::Adapter
88
+ end
89
+ end
90
+ end
91
+ end
@@ -46,6 +46,10 @@ module Portage
46
46
  offers = rank(stores.flat_map { |store| offers_for(store) })
47
47
  report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
48
48
  offers: offers, message: summary(candidates, stores, offers))
49
+ rescue Portage::Ucp::Client::MissingAgentProfileError
50
+ report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
51
+ message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — each store " \
52
+ "verifies it before answering a catalog search.")
49
53
  end
50
54
 
51
55
  private
@@ -135,12 +139,23 @@ module Portage
135
139
  # --- Step 3: ask the survivors what they stock ---
136
140
 
137
141
  def offers_for(store)
138
- products = CatalogProducts.from(store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS))
142
+ products = CatalogProducts.from(
143
+ store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS, meta: agent_meta)
144
+ )
139
145
  products.filter_map { |product| offer(store, product) }
146
+ rescue Portage::Ucp::Client::MissingAgentProfileError
147
+ raise
140
148
  rescue StandardError
141
149
  []
142
150
  end
143
151
 
152
+ # Real UCP servers fetch this URL to verify the caller's identity
153
+ # before answering any call (see Transports::Http) — the own-store
154
+ # loopback path ignores it harmlessly.
155
+ def agent_meta
156
+ { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
157
+ end
158
+
144
159
  def offer(store, product)
145
160
  amount, currency = price_of(product)
146
161
  return nil if @max_price && amount && amount > @max_price
@@ -0,0 +1,225 @@
1
+ require "fileutils"
2
+ require "portage/ucp"
3
+
4
+ module Portage
5
+ module Cli
6
+ module Generate
7
+ # `portage generate adapter Foo` — scaffolds a new adapter gem shaped
8
+ # like the bundled ones (portage-ucp-etsy is the template this follows
9
+ # most closely: gemspec depending on portage-ucp, Adapter subclass,
10
+ # conformance spec). Every capability method is stubbed as `= super`,
11
+ # reflected straight off Portage::Ucp::Adapter's own signatures rather
12
+ # than hand-copied, so the scaffold can't drift from the real contract
13
+ # the way a hardcoded template would the next time the contract grows.
14
+ class Adapter
15
+ def initialize(name:, dir: nil)
16
+ @module_name = camelize(name)
17
+ @slug = underscore(name)
18
+ @dir = dir || "portage-ucp-#{@slug}"
19
+ end
20
+
21
+ def call
22
+ FileUtils.mkdir_p(File.join(@dir, "lib", "portage", "ucp", @slug))
23
+ FileUtils.mkdir_p(File.join(@dir, "spec", "portage", "ucp", @slug))
24
+ write_gemspec
25
+ write_gemfile
26
+ write_rakefile
27
+ write_rubocop
28
+ write_lib_entrypoint
29
+ write_version
30
+ write_adapter
31
+ write_spec_helper
32
+ write_conformance_spec
33
+ @dir
34
+ end
35
+
36
+ private
37
+
38
+ def camelize(str) = str.to_s.split(/[_-]/).map { |w| w[0].upcase + w[1..] }.join
39
+ def underscore(str) = str.to_s.gsub(/([a-z\d])([A-Z])/, '\1_\2').tr("-", "_").downcase
40
+
41
+ def write(relative_path, content)
42
+ File.write(File.join(@dir, relative_path), content)
43
+ end
44
+
45
+ def write_gemspec
46
+ write "#{@dir}.gemspec", <<~RUBY
47
+ require_relative "lib/portage/ucp/#{@slug}/version"
48
+
49
+ Gem::Specification.new do |spec|
50
+ spec.name = "portage-ucp-#{@slug}"
51
+ spec.version = Portage::Ucp::#{@module_name}::VERSION
52
+ spec.summary = "#{@module_name} adapter for portage-ucp"
53
+ spec.description = "Implements Portage::Ucp::Adapter against #{@module_name}'s API. " \\
54
+ "Generic only — no merchant-specific business logic."
55
+ spec.authors = ["Tom Whitbread"]
56
+ spec.license = "MIT"
57
+ spec.homepage = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
58
+ spec.required_ruby_version = ">= 3.2"
59
+
60
+ spec.files = Dir["lib/**/*.rb", "README.md", "LICENSE", "CHANGELOG.md"]
61
+ spec.require_paths = ["lib"]
62
+
63
+ spec.add_dependency "portage-ucp", "~> 0.6"
64
+
65
+ spec.add_development_dependency "rspec", "~> 3.13"
66
+ spec.add_development_dependency "rubocop", "~> 1.88"
67
+ spec.add_development_dependency "webmock", "~> 3.24"
68
+ spec.metadata["source_code_uri"] = "https://github.com/tomtom87/Portage/tree/main/#{@dir}"
69
+ spec.metadata["rubygems_mfa_required"] = "true"
70
+ end
71
+ RUBY
72
+ end
73
+
74
+ def write_gemfile
75
+ write "Gemfile", <<~RUBY
76
+ source "https://rubygems.org"
77
+
78
+ gemspec
79
+
80
+ gem "portage-ucp", path: "../portage-ucp"
81
+ RUBY
82
+ end
83
+
84
+ def write_rakefile
85
+ write "Rakefile", <<~RUBY
86
+ require "bundler/gem_tasks"
87
+ require "rspec/core/rake_task"
88
+
89
+ RSpec::Core::RakeTask.new(:spec)
90
+
91
+ task default: :spec
92
+ RUBY
93
+ end
94
+
95
+ def write_rubocop
96
+ write ".rubocop.yml", <<~YAML
97
+ AllCops:
98
+ NewCops: enable
99
+ TargetRubyVersion: 3.2
100
+ SuggestExtensions: false
101
+
102
+ Style/FrozenStringLiteralComment:
103
+ Enabled: false
104
+
105
+ Style/StringLiterals:
106
+ EnforcedStyle: double_quotes
107
+
108
+ Style/Documentation:
109
+ Enabled: false
110
+
111
+ Metrics/BlockLength:
112
+ Exclude:
113
+ - "spec/**/*.rb"
114
+ - "*.gemspec"
115
+
116
+ # The scaffolded Adapter subclass is all `= super` stubs until
117
+ # each one is replaced with a real implementation — rubocop
118
+ # would otherwise flag every one of them as a useless override.
119
+ Lint/UselessMethodDefinition:
120
+ Exclude:
121
+ - "lib/**/adapter.rb"
122
+
123
+ Gemspec/DevelopmentDependencies:
124
+ Enabled: false
125
+
126
+ Gemspec/RequireMFA:
127
+ Enabled: false
128
+ YAML
129
+ end
130
+
131
+ def write_lib_entrypoint
132
+ write "lib/portage/ucp/#{@slug}.rb", <<~RUBY
133
+ require "portage/ucp"
134
+ require_relative "#{@slug}/version"
135
+ require_relative "#{@slug}/adapter"
136
+ RUBY
137
+ end
138
+
139
+ def write_version
140
+ write "lib/portage/ucp/#{@slug}/version.rb", <<~RUBY
141
+ module Portage
142
+ module Ucp
143
+ module #{@module_name}
144
+ VERSION = "0.1.0".freeze
145
+ end
146
+ end
147
+ end
148
+ RUBY
149
+ end
150
+
151
+ def write_adapter
152
+ write "lib/portage/ucp/#{@slug}/adapter.rb", <<~RUBY
153
+ module Portage
154
+ module Ucp
155
+ module #{@module_name}
156
+ # TODO: fill in a real client/constructor, then replace each
157
+ # `= super` stub below with a real implementation. Leave a
158
+ # method as `= super` to leave that capability unadvertised
159
+ # (Portage::Ucp::Capability#advertised_for? — see adapter.rb).
160
+ class Adapter < Portage::Ucp::Adapter
161
+ #{adapter_method_stubs}
162
+ end
163
+ end
164
+ end
165
+ end
166
+ RUBY
167
+ end
168
+
169
+ # One `= super` stub per public method Portage::Ucp::Adapter itself
170
+ # defines, with the same keyword signature — reflected via
171
+ # Method#parameters rather than hardcoded so this never drifts from
172
+ # the contract's actual shape.
173
+ def adapter_method_stubs
174
+ Portage::Ucp::Adapter.instance_methods(false).sort.filter_map do |name|
175
+ next if name.to_s.end_with?("_supported?")
176
+
177
+ params = Portage::Ucp::Adapter.instance_method(name).parameters
178
+ " def #{name}(#{signature(params)}) = super"
179
+ end.join("\n")
180
+ end
181
+
182
+ def signature(params)
183
+ params.map do |type, param_name|
184
+ case type
185
+ when :keyreq then "#{param_name}:"
186
+ when :key then "#{param_name}: nil"
187
+ end
188
+ end.compact.join(", ")
189
+ end
190
+
191
+ def write_spec_helper
192
+ write "spec/spec_helper.rb", <<~RUBY
193
+ require "portage/ucp/#{@slug}"
194
+ require "webmock/rspec"
195
+
196
+ WebMock.disable_net_connect!
197
+
198
+ RSpec.configure do |config|
199
+ config.expect_with(:rspec) { |c| c.syntax = :expect }
200
+ config.disable_monkey_patching!
201
+ config.order = :random
202
+ end
203
+ RUBY
204
+ end
205
+
206
+ def write_conformance_spec
207
+ write "spec/portage/ucp/#{@slug}/conformance_spec.rb", <<~RUBY
208
+ require "spec_helper"
209
+ require "portage/ucp/rspec"
210
+
211
+ RSpec.describe Portage::Ucp::#{@module_name}::Adapter do
212
+ let(:adapter) { described_class.new }
213
+ let(:existing_product_id) { "TODO-a-real-catalog-id" }
214
+
215
+ # TODO: stub whatever HTTP calls the kit's examples below need —
216
+ # see portage-ucp-etsy/spec/.../conformance_spec.rb for a worked
217
+ # example of stubbing just enough for a catalog+checkout adapter.
218
+ it_behaves_like "a portage adapter"
219
+ end
220
+ RUBY
221
+ end
222
+ end
223
+ end
224
+ end
225
+ end
@@ -0,0 +1,142 @@
1
+ require "openssl"
2
+ require "digest"
3
+ require "base64"
4
+ require "json"
5
+ require "fileutils"
6
+ require "portage/ucp"
7
+
8
+ module Portage
9
+ module Cli
10
+ module Generate
11
+ # Generates and (re)publishes portage-cli's own UCP agent-identity
12
+ # profile document — the `meta.ucp-agent.profile` URL real UCP servers
13
+ # fetch and validate before answering any catalog/cart/checkout call
14
+ # (confirmed live against Shopify's 2026-08-25 rollout: an unreachable
15
+ # or malformed profile 422s with `profile_unreachable`/`profile_malformed`
16
+ # before the request's shape is even considered). This class only
17
+ # produces the document and its signing key; hosting it at a stable,
18
+ # public URL (HTTPS, no redirects, `Cache-Control: public, max-age>=60`
19
+ # — see docs/agent-profile.md) is a separate, one-time infra step.
20
+ #
21
+ # Deliberately NOT Portage::Ucp::Manifest, despite the surface
22
+ # similarity: Manifest builds the *business's* /.well-known/ucp
23
+ # document and nests `signing_keys` inside its own "ucp" envelope
24
+ # (portage-ucp/lib/portage/ucp/manifest.rb); the UCP spec's agent
25
+ # profile is a different document describing the *agent* calling in,
26
+ # and puts `signing_keys` as a sibling of "ucp" at the document root.
27
+ # Same-looking key material, structurally different document — not
28
+ # interchangeable, so this doesn't subclass or reuse Manifest.
29
+ #
30
+ # `Portage::Ucp::Manifest::UCP_VERSION` is reused as-is (not
31
+ # redeclared) so the two documents can never drift to different spec
32
+ # versions by accident.
33
+ #
34
+ # Rotation is the future-proofing this exists for: re-running with
35
+ # `rotate: true` keeps every key already published (so a request
36
+ # signed under an older `kid` keeps verifying while callers migrate)
37
+ # and adds one freshly generated key alongside them. Nothing here ever
38
+ # drops a key — retiring one is a deliberate, separate edit once
39
+ # nothing signs with it any more.
40
+ class AgentProfile
41
+ UCP_VERSION = Portage::Ucp::Manifest::UCP_VERSION
42
+
43
+ Key = Struct.new(:kid, :jwk, :private_pem, keyword_init: true)
44
+
45
+ # @param out [String] path to write the public profile JSON document
46
+ # @param key_out [String] path to write the new private key's PEM —
47
+ # caller's responsibility to keep this out of version control
48
+ # @param rotate [Boolean] keep existing signing_keys from `out` (if
49
+ # it already exists) and add a new one, instead of replacing them
50
+ # @return [Hash] { profile_path:, private_key_path:, kid: } — the
51
+ # kid of the newly generated key
52
+ def self.generate(out:, key_out:, rotate: false)
53
+ new(out: out, key_out: key_out, rotate: rotate).generate
54
+ end
55
+
56
+ def initialize(out:, key_out:, rotate: false)
57
+ @out = out
58
+ @key_out = key_out
59
+ @rotate = rotate
60
+ end
61
+
62
+ def generate
63
+ new_key = generate_key
64
+ doc = build_document(carried_forward_keys + [new_key.jwk])
65
+
66
+ write_profile(doc)
67
+ write_private_key(new_key.private_pem)
68
+
69
+ { profile_path: @out, private_key_path: @key_out, kid: new_key.kid }
70
+ end
71
+
72
+ private
73
+
74
+ def carried_forward_keys
75
+ return [] unless @rotate && File.exist?(@out)
76
+
77
+ JSON.parse(File.read(@out)).fetch("signing_keys", [])
78
+ rescue JSON::ParserError
79
+ []
80
+ end
81
+
82
+ def build_document(signing_keys)
83
+ {
84
+ "ucp" => {
85
+ "version" => UCP_VERSION,
86
+ "services" => {},
87
+ "capabilities" => {},
88
+ "payment_handlers" => {}
89
+ },
90
+ "signing_keys" => signing_keys
91
+ }
92
+ end
93
+
94
+ def write_profile(doc)
95
+ FileUtils.mkdir_p(File.dirname(@out))
96
+ File.write(@out, "#{JSON.pretty_generate(doc)}\n")
97
+ end
98
+
99
+ def write_private_key(pem)
100
+ FileUtils.mkdir_p(File.dirname(@key_out))
101
+ File.write(@key_out, pem)
102
+ File.chmod(0o600, @key_out)
103
+ end
104
+
105
+ # A JWK's `kid` is derived from the key material itself (RFC 7638
106
+ # thumbprint) rather than assigned, so it can't drift from the key
107
+ # it names — a caller can't accidentally publish a profile where a
108
+ # `kid` points at the wrong entry.
109
+ def generate_key
110
+ pkey = OpenSSL::PKey::EC.generate("prime256v1")
111
+ x_b64, y_b64 = coordinates(pkey)
112
+ kid = thumbprint(x_b64, y_b64)
113
+
114
+ jwk = { "kid" => kid, "kty" => "EC", "crv" => "P-256", "x" => x_b64, "y" => y_b64,
115
+ "use" => "sig", "alg" => "ES256" }
116
+
117
+ Key.new(kid: kid, jwk: jwk, private_pem: pkey.to_pem)
118
+ end
119
+
120
+ # Raw uncompressed EC point encoding: 0x04 || x (32 bytes) || y (32
121
+ # bytes) for P-256 — see Portage::Ucp::Security::Signature::CURVES,
122
+ # which decodes the same layout in reverse when verifying.
123
+ def coordinates(pkey)
124
+ octets = pkey.public_key.to_bn.to_s(2)
125
+ coord_bytes = 32
126
+ x = octets[1, coord_bytes]
127
+ y = octets[1 + coord_bytes, coord_bytes]
128
+ [url_b64(x), url_b64(y)]
129
+ end
130
+
131
+ def thumbprint(x_b64, y_b64)
132
+ canonical = JSON.generate({ "crv" => "P-256", "kty" => "EC", "x" => x_b64, "y" => y_b64 })
133
+ url_b64(Digest::SHA256.digest(canonical))
134
+ end
135
+
136
+ def url_b64(bytes)
137
+ Base64.urlsafe_encode64(bytes, padding: false)
138
+ end
139
+ end
140
+ end
141
+ end
142
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.4.1".freeze
3
+ VERSION = "0.6.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -9,6 +9,9 @@ require_relative "cli/find"
9
9
  require_relative "cli/compare"
10
10
  require_relative "cli/history"
11
11
  require_relative "cli/payment_methods"
12
+ require_relative "cli/doctor"
13
+ require_relative "cli/generate/adapter"
14
+ require_relative "cli/generate/agent_profile"
12
15
 
13
16
  module Portage
14
17
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -37,23 +40,23 @@ module Portage
37
40
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
38
41
  [--velocity-count N --velocity-window-seconds N]
39
42
  [--allow HOST ...] [--clear-allowlist]
43
+ portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
44
+ portage generate adapter NAME [--dir DIR]
45
+ portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
40
46
  USAGE
41
47
 
48
+ COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
49
+ "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
50
+ "doctor" => :run_doctor, "generate" => :run_generate }.freeze
51
+
42
52
  # @param argv [Array<String>]
43
53
  # @return [Integer] process exit code
44
54
  def self.run(argv)
45
55
  command, *rest = argv
46
- case command
47
- when "buy" then run_buy(rest)
48
- when "find" then run_find(rest)
49
- when "compare" then run_compare(rest)
50
- when "history" then run_history(rest)
51
- when "payment" then run_payment(rest)
52
- when "policy" then run_policy(rest)
53
- else
54
- warn USAGE
55
- 1
56
- end
56
+ return send(COMMANDS[command], rest) if COMMANDS.key?(command)
57
+
58
+ warn USAGE
59
+ 1
57
60
  end
58
61
 
59
62
  # --- find ---
@@ -517,6 +520,84 @@ module Portage
517
520
  end
518
521
  private_class_method :format_payment_enroll
519
522
 
523
+ # --- doctor ---
524
+
525
+ def self.parse_doctor_options(argv)
526
+ opts = {}
527
+ OptionParser.new do |parser|
528
+ parser.on("--require FILE") { |v| opts[:require] = v }
529
+ parser.on("--adapter CLASS_NAME") { |v| opts[:adapter] = v }
530
+ parser.on("--json") { opts[:json] = true }
531
+ end.parse!(argv)
532
+ opts
533
+ end
534
+ private_class_method :parse_doctor_options
535
+
536
+ def self.run_doctor(argv)
537
+ opts = parse_doctor_options(argv)
538
+ require File.expand_path(opts[:require]) if opts[:require]
539
+ adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
540
+
541
+ findings = Doctor.new(adapter_class: adapter_class).call
542
+ puts opts[:json] ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
543
+ findings.empty? ? 0 : 1
544
+ end
545
+ private_class_method :run_doctor
546
+
547
+ def self.format_doctor(findings)
548
+ return "No issues found." if findings.empty?
549
+
550
+ findings.map { |f| "[#{f.check}] #{f.message}" }.join("\n")
551
+ end
552
+ private_class_method :format_doctor
553
+
554
+ # --- generate ---
555
+
556
+ def self.run_generate(argv)
557
+ kind, *rest = argv
558
+ case kind
559
+ when "adapter" then run_generate_adapter(rest)
560
+ when "agent-profile" then run_generate_agent_profile(rest)
561
+ else
562
+ warn USAGE
563
+ 1
564
+ end
565
+ end
566
+ private_class_method :run_generate
567
+
568
+ def self.run_generate_adapter(rest)
569
+ name, *rest = rest
570
+ unless name
571
+ warn USAGE
572
+ return 1
573
+ end
574
+
575
+ dir = nil
576
+ OptionParser.new { |parser| parser.on("--dir DIR") { |v| dir = v } }.parse!(rest)
577
+ path = Generate::Adapter.new(name: name, dir: dir).call
578
+ puts "Scaffolded #{path}/"
579
+ 0
580
+ end
581
+ private_class_method :run_generate_adapter
582
+
583
+ def self.run_generate_agent_profile(rest)
584
+ out = "agent-profile.json"
585
+ key_out = "agent-profile.key.pem"
586
+ rotate = false
587
+ OptionParser.new do |parser|
588
+ parser.on("--out FILE") { |v| out = v }
589
+ parser.on("--key-out FILE") { |v| key_out = v }
590
+ parser.on("--rotate") { rotate = true }
591
+ end.parse!(rest)
592
+
593
+ result = Generate::AgentProfile.generate(out: out, key_out: key_out, rotate: rotate)
594
+ puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
595
+ puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
596
+ "and off the machine that serves the public profile"
597
+ 0
598
+ end
599
+ private_class_method :run_generate_agent_profile
600
+
520
601
  # --- output ---
521
602
 
522
603
  def self.format_report(report)
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.4.1
4
+ version: 0.6.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-09-15 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,42 @@ dependencies:
16
15
  requirements:
17
16
  - - "~>"
18
17
  - !ruby/object:Gem::Version
19
- version: '0.6'
18
+ version: '0.8'
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.6'
25
+ version: '0.8'
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.3'
32
+ version: '0.4'
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.3'
39
+ version: '0.4'
40
+ - !ruby/object:Gem::Dependency
41
+ name: portage-ucp-journal
42
+ requirement: !ruby/object:Gem::Requirement
43
+ requirements:
44
+ - - "~>"
45
+ - !ruby/object:Gem::Version
46
+ version: '0.1'
47
+ type: :runtime
48
+ prerelease: false
49
+ version_requirements: !ruby/object:Gem::Requirement
50
+ requirements:
51
+ - - "~>"
52
+ - !ruby/object:Gem::Version
53
+ version: '0.1'
41
54
  - !ruby/object:Gem::Dependency
42
55
  name: rspec
43
56
  requirement: !ruby/object:Gem::Requirement
@@ -102,11 +115,13 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
102
115
  shopper. `portage find` covers the no-URL case: ask an allowlist, DuckDuckGo, Brave,
103
116
  or a Google Programmable Search engine which stores might sell something, keep the
104
117
  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:
118
+ no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
119
+ portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
120
+ read-only view of local purchase/transaction/order state); no single adapter gem
121
+ is a hard dependency.'
108
122
  executables:
109
123
  - portage
124
+ - portage-console
110
125
  extensions: []
111
126
  extra_rdoc_files: []
112
127
  files:
@@ -114,11 +129,16 @@ files:
114
129
  - LICENSE
115
130
  - README.md
116
131
  - exe/portage
132
+ - exe/portage-console
117
133
  - lib/portage/cli.rb
118
134
  - lib/portage/cli/buy.rb
119
135
  - lib/portage/cli/catalog_products.rb
120
136
  - lib/portage/cli/compare.rb
137
+ - lib/portage/cli/console.rb
138
+ - lib/portage/cli/doctor.rb
121
139
  - lib/portage/cli/find.rb
140
+ - lib/portage/cli/generate/adapter.rb
141
+ - lib/portage/cli/generate/agent_profile.rb
122
142
  - lib/portage/cli/history.rb
123
143
  - lib/portage/cli/payment_methods.rb
124
144
  - lib/portage/cli/payment_methods/env_backend.rb
@@ -135,7 +155,6 @@ metadata:
135
155
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
136
156
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
137
157
  rubygems_mfa_required: 'true'
138
- post_install_message:
139
158
  rdoc_options: []
140
159
  require_paths:
141
160
  - lib
@@ -150,8 +169,7 @@ required_rubygems_version: !ruby/object:Gem::Requirement
150
169
  - !ruby/object:Gem::Version
151
170
  version: '0'
152
171
  requirements: []
153
- rubygems_version: 3.5.22
154
- signing_key:
172
+ rubygems_version: 4.0.21
155
173
  specification_version: 4
156
174
  summary: portage — one CLI command to buy from any store, native UCP or not
157
175
  test_files: []