portage-cli 0.5.1 → 0.6.4

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: 2a0e9e7e0b7046fc20978a89fb06056bf1827df5efa45c47ddfb0b16f332d53f
4
- data.tar.gz: 9ab0f0c9caa507147fae5069dda27c86a92e4c26f273c9cbc14cee8fbfddf368
3
+ metadata.gz: 72d2d335f8f6d40abab7b9307f4e3dcfc21b2cb9dfc4fc5f67c490dd2d42e5ee
4
+ data.tar.gz: 98065dbee850dd5db2141731f45078526b00b877bbe67278b13c5b6ffa7a448e
5
5
  SHA512:
6
- metadata.gz: 247e88dba38ac3171cedccd90d0d1e56ff225cf8aa2526598c3d670391d79fc908fe9fe81735aa71f1c389516ae04c5c71512c61e56bc39802b92bd213d6b40a
7
- data.tar.gz: 07d42f21ba32010310394a39a664599c09e840a58cc1be6dd5bcbed7e9d0adf46639364f2bbd70ee70f63fcbfe9407a6d04af9ea75669cc5ae6a5f0f226c5226
6
+ metadata.gz: 2ad81909c54bbf4ca6e588c8b3f72b19d8d7788d4762754b0f38783a51ce44dca60cd6e9a2aba7f05faeb0e7ca122bece589884194c44b982d7009615322432f
7
+ data.tar.gz: 81a8c3098e6dad475ec1138c44de866be4a349a3bc57a133330c6684b9924b050eebd44f1440292f2f28d0cd87bb1f6c3cbd356c85ccc4f769e7162dbff1803b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,118 @@ All notable changes to this project are documented here. Format loosely follows
4
4
  [Keep a Changelog](https://keepachangelog.com/en/1.0.0/); this project is
5
5
  pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
+ ## [0.6.4] - 2026-09-22
8
+
9
+ - **Every dead-end hand-off pointed the shopper at the store's refund
10
+ policy.** `#checkout_url_of` took the first entry in the checkout's
11
+ `links` with a url, on the reasoning that not every backend types its
12
+ link entries. Live UCP stores put nothing *but* policy links there:
13
+ five third-party Shopify stores checked 2026-09-22 returned
14
+ `refund_policy`, `privacy_policy`, `terms_of_service`, `shipping_policy`
15
+ and `contact_information`, and never a checkout link — the checkout is
16
+ always at `continue_url`. So `requires_escalation`, permission-denied and
17
+ no-payment-token reports all handed over a policy page, `--auto-open`
18
+ opened it and `--notify-webhook` posted it. Now reads `continue_url`
19
+ first, and the `links` fallback skips policy/contact entries rather than
20
+ handing over a wrong URL.
21
+ - `adapter_flow` rescued `LoadError` and `StandardError` identically,
22
+ returning `nil` either way — correct once the adapter gem genuinely isn't
23
+ installed, wrong once it's live and its own call actually failed. A live
24
+ adapter's `StandardError` (e.g. "no payment_method configured on this
25
+ Adapter") now comes back as its own report instead of the generic "no
26
+ automated path" dead end, distinguishable from "no adapter for this
27
+ platform" by `source`.
28
+
29
+ ## [0.6.3] - 2026-09-22
30
+
31
+ - A store refusing a cart or checkout call on its own terms — out of stock,
32
+ a line it won't take, an expired cart — is reported as a normal outcome
33
+ carrying the store's own sentence and its `continue_url`, instead of
34
+ escaping `Buy#call` as an unhandled `Client::ServerError`. It printed a
35
+ Ruby backtrace whose "message" was the store's entire several-kilobyte
36
+ `ucp` error envelope; confirmed live 2026-09-22 against a genuinely
37
+ sold-out variant. Same posture `requires_escalation` and
38
+ `PaymentPermissionError` already had.
39
+ - Requires `portage-ucp-client ~> 0.6` (was `~> 0.5`), which is what
40
+ `Buy#complete`'s `rescue Client::PaymentPermissionError` has actually
41
+ needed since 0.6.2 — that constant landed in client 0.6.0, and a `rescue`
42
+ naming a missing constant raises `NameError` over the top of whatever
43
+ error it was meant to catch.
44
+
45
+ ## [0.6.2] - 2026-09-22
46
+
47
+ - `Buy#complete` treats a `Client::PaymentPermissionError` from
48
+ `complete_checkout` (this agent not yet granted checkout-completion on the
49
+ store) as a normal outcome rather than a failure — same posture as
50
+ `requires_escalation`: the report carries the checkout's `continue_url`
51
+ so the shopper can finish on the merchant's own checkout page.
52
+ - Every dead-end `buy` outcome that hands a shopper a `checkout_url`
53
+ (`requires_escalation`, permission denied, no `--payment-token`) can now
54
+ auto-open that link in the shopper's browser and/or POST it to a webhook,
55
+ instead of leaving it as inert text/JSON. Both off by default; opt in with
56
+ `--auto-open`/`--notify-webhook <url>`, `PORTAGE_AUTO_OPEN_CHECKOUT`/
57
+ `PORTAGE_NOTIFY_WEBHOOK_URL`, or `~/.portage/config.json`
58
+ (`auto_open_checkout`/`notify_webhook_url`), in that precedence order.
59
+ Never fires on `--dry-run`. Best-effort throughout: a failed open or POST
60
+ never fails the buy, and surfaces instead as `handoff: {opened:,
61
+ notified:, notify_error:}` on the report. New `CheckoutHandoff`, `Notifier`,
62
+ and `Config` classes; `portage-ucp` core is untouched.
63
+
64
+ ## [0.6.1] - 2026-09-22
65
+
66
+ - `portage generate agent-profile` emits the capability identifiers a UCP
67
+ server actually resolves an agent's tool registry from. It was emitting
68
+ `dev.ucp.shopping.catalog` — reusing `Portage::Ucp::Capabilities::CATALOG
69
+ .name`, which is correct for a business's own manifest, where one Capability
70
+ owns all three catalog actions, and wrong here: the registry is per action
71
+ (`dev.ucp.shopping.catalog.search`, `dev.ucp.shopping.catalog.lookup`). A
72
+ profile declaring the coarse name resolved to zero catalog tools, and stores
73
+ reported that as `-32602 Tool not found: search_catalog` seconds after
74
+ `tools/list` advertised it. Versions are now spec revisions (`2026-08-25`)
75
+ rather than `"1"`, and `ucp.services` declares the shopping service instead
76
+ of being left `{}`. The checked-in
77
+ `agent-profile/agent-profile.json` is regenerated, existing signing keys
78
+ kept. See `docs/ucp-tool-gating-investigation.md`.
79
+ - New `Portage::Cli::BuyerContext` builds the UCP `context` object from
80
+ `PORTAGE_SHIP_COUNTRY`/`PORTAGE_SHIP_REGION`/`PORTAGE_SHIP_POSTAL_CODE`,
81
+ `PORTAGE_CURRENCY` and `PORTAGE_LANGUAGE`. `buy` and `find` send it on every
82
+ catalog and checkout call — without it a real store builds an empty cart and
83
+ calls it sold out. Partial by design, unlike `ShippingProfile`, which stays
84
+ all-or-nothing because a half-filled address can't be submitted.
85
+
86
+ ## [0.6.0] - 2026-09-17
87
+
88
+ - Fixed `buy`/`find` crashing with a raw `Faraday::UnprocessableContentError`
89
+ against a real UCP store, once manifest parsing succeeded — the actual
90
+ `search_catalog`/`create_checkout` calls were still built in the wrong
91
+ wire shape (see `portage-ucp-client` 0.4.0). Both commands
92
+ now report a clear, actionable message instead: set `PORTAGE_AGENT_PROFILE`
93
+ when it's missing, or surface the store's rejection cleanly when it's set
94
+ but not accepted.
95
+ - Added `PORTAGE_AGENT_PROFILE` — required for `find`/`buy` against a real,
96
+ external UCP store (not your own store via an adapter). No default; see
97
+ `.env.example`.
98
+ - Fixed `buy` sending a catalog product's own id as the purchasable line
99
+ item — correct for a backend where "the product" and "the thing you add
100
+ to a cart" share one id, but wrong for Shopify (confirmed live: every
101
+ `portage buy` against a real Shopify test store failed with "Invalid id",
102
+ since Storefront's cart takes a `ProductVariant` GID, not the parent
103
+ `Product` GID `search_catalog` returns as `id`). `Buy#full_buy` and
104
+ `#redirect_checkout` now build `line_items` from a product's first/
105
+ default variant id when one exists, falling back to the product id
106
+ otherwise; `--product-id` matching (`#product_id_of`) is unchanged, since
107
+ it still needs to match the catalog-level id `find`/`compare` show the
108
+ caller.
109
+ - Added `portage generate agent-profile`, which writes a real UCP
110
+ agent-identity document (the JSON a store fetches from the
111
+ `meta.ucp-agent.profile` URL to decide whether to answer at all).
112
+ `portage-cli`'s own generated profile is checked in at
113
+ `agent-profile/agent-profile.json` and published to a stable URL by
114
+ `.github/workflows/publish-agent-profile.yml`, which is what
115
+ `PORTAGE_AGENT_PROFILE` defaults to pointing at.
116
+ - Widens the `portage-ucp` pin to `~> 0.8` and the `portage-ucp-client` pin
117
+ to `~> 0.4` so this gem installs alongside the 0.8.0-line releases.
118
+
7
119
  ## [0.5.1] - 2026-09-16
8
120
 
9
121
  - No behavior change — 0.5.0 was built and pushed with `gem build` run from
@@ -5,6 +5,8 @@ require "portage/ucp"
5
5
  require "portage/ucp/client"
6
6
  require "portage/ucp/journal"
7
7
  require_relative "payment_methods"
8
+ require_relative "checkout_handoff"
9
+ require_relative "notifier"
8
10
 
9
11
  module Portage
10
12
  module Cli
@@ -27,7 +29,16 @@ module Portage
27
29
  # whatever the catalog search happens to rank first — how `portage
28
30
  # find` hands a picked offer over without the ranking being guessed
29
31
  # twice.
30
- def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil)
32
+ # @param auto_open [Boolean, nil] per-invocation override for whether a
33
+ # dead-end checkout_url auto-opens in the shopper's browser — nil
34
+ # (the default) defers to PORTAGE_AUTO_OPEN_CHECKOUT / config.json
35
+ # (see CheckoutHandoff).
36
+ # @param notify_webhook [String, nil] per-invocation override for the
37
+ # webhook URL a dead-end checkout_url is POSTed to — nil (the
38
+ # default) defers to PORTAGE_NOTIFY_WEBHOOK_URL / config.json (see
39
+ # Notifier).
40
+ def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
41
+ auto_open: nil, notify_webhook: nil)
31
42
  raw = url.to_s.strip
32
43
  raw = "https://#{raw}" unless raw =~ %r{\Ahttps?://}i
33
44
  @uri = URI.parse(raw)
@@ -37,8 +48,13 @@ module Portage
37
48
  @yes = yes
38
49
  @dry_run = dry_run
39
50
  @product_id = product_id
51
+ @auto_open = auto_open
52
+ @notify_webhook = notify_webhook
40
53
  end
41
54
 
55
+ # Link `type`s that are never the checkout — see #checkout_url_of.
56
+ POLICY_LINK_TYPES = /policy|policies|terms|contact|privacy|legal|imprint/i
57
+
42
58
  def call
43
59
  session = discover(@uri)
44
60
  return native_flow(session) if session
@@ -61,6 +77,14 @@ module Portage
61
77
 
62
78
  def discover(url)
63
79
  Portage::Ucp::Client.discover(url.to_s)
80
+ rescue Portage::Ucp::Client::ManifestShapeError => e
81
+ # The store *is* running UCP — this client just couldn't parse its
82
+ # manifest. Distinct from a genuine 404/unreachable host below:
83
+ # falling through silently there would hide a bug in this gem behind
84
+ # the same "no automated path" message a store with no UCP support
85
+ # gets, so this warns instead.
86
+ warn "portage: #{url} serves a UCP manifest this client couldn't parse (#{e.message})"
87
+ nil
64
88
  rescue Portage::Ucp::Client::DiscoveryError
65
89
  nil
66
90
  end
@@ -71,6 +95,54 @@ module Portage
71
95
  else
72
96
  catalog_only(session)
73
97
  end
98
+ rescue Portage::Ucp::Client::MissingAgentProfileError, Portage::Ucp::Client::UnsupportedWireShapeError,
99
+ Portage::Ucp::Client::ServerError, MCP::Client::RequestHandlerError => e
100
+ native_flow_error_report(e)
101
+ end
102
+
103
+ def native_flow_error_report(error)
104
+ case error
105
+ when Portage::Ucp::Client::MissingAgentProfileError
106
+ build_report(source: "native_ucp", browse: false, checkout: false,
107
+ message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — " \
108
+ "#{@uri} verifies it before answering any UCP call.")
109
+ when Portage::Ucp::Client::UnsupportedWireShapeError
110
+ build_report(source: "native_ucp", browse: true, checkout: false,
111
+ message: "Can't complete checkout on #{@uri} yet: #{error.message}")
112
+ when Portage::Ucp::Client::ServerError
113
+ server_error_report(error)
114
+ else
115
+ # MCP::Client::RequestHandlerError doesn't retain the server's JSON
116
+ # error body on this path, so this can't quote the server's own
117
+ # explanation — a common cause is PORTAGE_AGENT_PROFILE not
118
+ # pointing at a real, JSON agent-profile document the store's UCP
119
+ # endpoint accepts.
120
+ build_report(source: "native_ucp", browse: false, checkout: false,
121
+ message: "#{@uri} rejected the request (#{error.message}) — if PORTAGE_AGENT_PROFILE " \
122
+ "is set, check it points at a real agent-profile document the store accepts.")
123
+ end
124
+ end
125
+
126
+ # A store refusing a cart/checkout call on its own terms — out of stock,
127
+ # a line it won't accept, a cart that expired — is an answer, not a
128
+ # crash. This used to escape `#call` as an unhandled ServerError,
129
+ # printing a Ruby backtrace whose "message" was the server's entire
130
+ # several-kilobyte `ucp` envelope (confirmed live 2026-09-22: a
131
+ # genuinely sold-out variant on a Shopify store). Report the server's
132
+ # own sentence instead, and hand back the `continue_url` it supplied so
133
+ # the shopper has somewhere to go — same posture as
134
+ # #escalation_report/#permission_denied_report.
135
+ #
136
+ # Deliberately not routed through #hand_off: that fires the auto-open
137
+ # and webhook side effects, which belong to a checkout this agent
138
+ # actually built. There's no checkout here — the call that failed is
139
+ # what would have created one.
140
+ def server_error_report(error)
141
+ url = error.continue_url
142
+ build_report(
143
+ source: "native_ucp", browse: true, checkout: false, checkout_url: url,
144
+ message: "#{@uri} couldn't complete this: #{error.summary}#{url && " — finish it at #{url}"}"
145
+ )
74
146
  end
75
147
 
76
148
  def catalog_only(session)
@@ -113,14 +185,29 @@ module Portage
113
185
  env = Portage::Ucp::Resolver.env_for(platform)
114
186
  return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
115
187
 
116
- adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
188
+ begin
189
+ adapter = Portage::Ucp::Resolver.build_adapter(platform, env)
190
+ rescue LoadError
191
+ return nil
192
+ end
193
+
194
+ run_adapter_flow(adapter, platform)
195
+ end
196
+
197
+ # Once the adapter gem is installed and the adapter itself is live, a
198
+ # `StandardError` it raises is a real, actionable failure (e.g. "no
199
+ # payment_method configured") — surface it instead of falling through
200
+ # to `dead_end`'s generic "visit it yourself" message, which would hide
201
+ # it identically to "there's no adapter for this platform at all".
202
+ def run_adapter_flow(adapter, platform)
117
203
  if adapter_supports_checkout?(adapter)
118
204
  full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
119
205
  else
120
206
  catalog_only_adapter(adapter, platform)
121
207
  end
122
- rescue LoadError, StandardError
123
- nil
208
+ rescue StandardError => e
209
+ build_report(source: "adapter:#{platform.name}", browse: false, checkout: false,
210
+ message: "#{platform.name} adapter error: #{e.message}")
124
211
  end
125
212
 
126
213
  def adapter_supports_checkout?(adapter)
@@ -156,8 +243,9 @@ module Portage
156
243
  def redirect_checkout(adapter, products)
157
244
  return nil if products.empty? || !Portage::Ucp::Capabilities::CHECKOUT.advertised_for?(adapter)
158
245
 
159
- adapter.create_checkout(line_items: [{ product_id: products.first.id, quantity: @qty }],
160
- idempotency_key: "portage-buy-#{products.first.id}")
246
+ item_id = products.first.variants&.first&.id || products.first.id
247
+ adapter.create_checkout(line_items: [{ product_id: item_id, quantity: @qty }],
248
+ idempotency_key: "portage-buy-#{item_id}")
161
249
  rescue StandardError
162
250
  nil
163
251
  end
@@ -178,8 +266,9 @@ module Portage
178
266
  message: no_match_message)
179
267
  end
180
268
 
181
- checkout = session.create_checkout(line_items: [{ product_id: product_id_of(product), quantity: @qty }],
182
- fulfillment: requested_fulfillment(fulfillment_adapter))
269
+ checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
270
+ fulfillment: requested_fulfillment(fulfillment_adapter),
271
+ context: buyer_context, meta: agent_meta)
183
272
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
184
273
  finish_checkout(session, source, products, checkout)
185
274
  end
@@ -264,6 +353,20 @@ module Portage
264
353
  product["id"]
265
354
  end
266
355
 
356
+ # `create_checkout`'s `line_items[].product_id` is the conformance
357
+ # kit's overloaded name (portage-ucp/lib/portage/ucp/rspec.rb) for
358
+ # "whatever id this adapter's cart actually takes" — for a backend
359
+ # where a product's variants have their own id (confirmed live on
360
+ # Shopify: a ProductVariant GID, distinct from the parent Product
361
+ # GID), that's the first/default variant, not the catalog id
362
+ # #product_id_of returns for display/--product-id matching. A
363
+ # product with no variants (or a backend that doesn't distinguish
364
+ # the two) falls back to the product id unchanged (see
365
+ # docs/design-log.md §41).
366
+ def line_item_id_of(product)
367
+ product["variants"]&.first&.dig("id") || product_id_of(product)
368
+ end
369
+
267
370
  def finish_checkout(session, source, products, checkout)
268
371
  status = checkout["status"]
269
372
  return escalation_report(source, products, checkout) if status == "requires_escalation"
@@ -276,13 +379,87 @@ module Portage
276
379
  def complete(session, source, products, checkout)
277
380
  @payment_token ||= PaymentMethods.default
278
381
  unless @payment_token
279
- return checkout_report(source, products, checkout,
280
- message: "No --payment-token given, and no default payment method on file — " \
281
- "run `portage payment enroll` or pass --payment-token.")
382
+ url = checkout_url_of(checkout)
383
+ return checkout_report(
384
+ source, products, checkout,
385
+ checkout_url: url, handoff: hand_off(checkout, reason: "no_payment_token", source: source),
386
+ message: "No --payment-token given, and no default payment method on file — run " \
387
+ "`portage payment enroll` or pass --payment-token, or visit the link to " \
388
+ "finish this checkout yourself."
389
+ )
282
390
  end
283
391
 
284
392
  completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
285
393
  checkout_report(source, products, completed, message: "Purchased.")
394
+ rescue Portage::Ucp::Client::PaymentPermissionError
395
+ permission_denied_report(source, products, checkout)
396
+ end
397
+
398
+ # Same posture as #escalation_report: a completion this agent isn't
399
+ # granted permission for is a normal outcome, not a failure — the
400
+ # shopper finishes on the merchant's own continue_url/checkout link,
401
+ # same hand-off requires_escalation already uses (see
402
+ # Client::PaymentPermissionError).
403
+ def permission_denied_report(source, products, checkout)
404
+ url = checkout_url_of(checkout)
405
+ checkout_report(
406
+ source, products, checkout,
407
+ checkout_url: url, handoff: hand_off(checkout, reason: "permission_denied", source: source),
408
+ message: "This agent isn't yet granted permission to complete checkout on this store — " \
409
+ "visit the link to finish it yourself."
410
+ )
411
+ end
412
+
413
+ # Never fires on --dry-run (a dry run creates a real checkout but never
414
+ # attempts completion — auto-opening/notifying over a preview run would
415
+ # be actively wrong), and never fires without a checkout_url to hand
416
+ # off. Best-effort: a failed open or failed webhook POST never raises
417
+ # out of #call (see CheckoutHandoff, Notifier), so the checkout itself
418
+ # — created, or correctly escalated — stays the outcome of record
419
+ # either way.
420
+ def hand_off(checkout, reason:, source:)
421
+ url = checkout_url_of(checkout)
422
+ return nil if @dry_run || url.nil?
423
+
424
+ opened = CheckoutHandoff.new(auto_open: @auto_open).call(url)
425
+ error = notifier.call(event: "checkout_handoff", reason: reason, checkout_url: url,
426
+ checkout_id: checkout["id"], source: source, totals: checkout["totals"])
427
+ { url: url, opened: opened, notified: notifier.enabled? && error.nil?, notify_error: error }
428
+ end
429
+
430
+ def notifier
431
+ @notifier ||= Notifier.new(webhook_url: @notify_webhook)
432
+ end
433
+
434
+ # Every checkout that can't be finished by this process — no
435
+ # permission, no token, or an explicit requires_escalation — hands the
436
+ # shopper a URL rather than leaving them at a dead end.
437
+ #
438
+ # `continue_url` first, because on a real store it is the only field
439
+ # that ever holds the checkout. This used to be
440
+ # `links.find { |l| l["url"] }` on the reasoning that not every
441
+ # backend's link entries name a type — but live UCP stores put nothing
442
+ # *except* policy links in `links`: five third-party Shopify stores
443
+ # checked 2026-09-22 returned `refund_policy`, `privacy_policy`,
444
+ # `terms_of_service`, `shipping_policy`, `contact_information` and
445
+ # nothing else, with the checkout at `continue_url` every time. So the
446
+ # old "first link with a url" handed the shopper a refund policy on
447
+ # every real store, `--auto-open` opened it, and `--notify-webhook`
448
+ # posted it.
449
+ #
450
+ # The `links` fallback stays for backends whose checkout genuinely
451
+ # lives there, but skips anything named as a policy or contact link:
452
+ # for a hand-off, no URL is a better answer than the wrong one, since
453
+ # the report and the message both then say there's nowhere to go
454
+ # instead of pointing somewhere useless.
455
+ def checkout_url_of(checkout)
456
+ checkout["continue_url"] || checkout_link_url(checkout)
457
+ end
458
+
459
+ def checkout_link_url(checkout)
460
+ Array(checkout["links"])
461
+ .reject { |l| l["type"].to_s.match?(POLICY_LINK_TYPES) }
462
+ .find { |l| l["url"] }&.fetch("url", nil)
286
463
  end
287
464
 
288
465
  def confirmed?
@@ -290,9 +467,12 @@ module Portage
290
467
  end
291
468
 
292
469
  def escalation_report(source, products, checkout)
293
- checkout_report(source, products, checkout,
294
- checkout_url: checkout["links"]&.find { |l| l["url"] }&.fetch("url", nil),
295
- message: "Checkout requires buyer escalation — visit the link to complete it.")
470
+ url = checkout_url_of(checkout)
471
+ checkout_report(
472
+ source, products, checkout,
473
+ checkout_url: url, handoff: hand_off(checkout, reason: "requires_escalation", source: source),
474
+ message: "Checkout requires buyer escalation — visit the link to complete it."
475
+ )
296
476
  end
297
477
 
298
478
  def dry_run_report(source, products, checkout)
@@ -307,14 +487,30 @@ module Portage
307
487
  # wants to see (id/status/totals) onto the report, rather than nesting
308
488
  # the raw hash under a key that'd collide with the boolean `checkout:`
309
489
  # field the output struct already reserves (§ output shape).
310
- def checkout_report(source, products, checkout, message:, checkout_url: nil)
490
+ def checkout_report(source, products, checkout, message:, checkout_url: nil, handoff: nil)
311
491
  build_report(source: source, browse: true, checkout: true, products: products, message: message,
312
492
  checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
313
- totals: checkout["totals"])
493
+ totals: checkout["totals"], handoff: handoff)
314
494
  end
315
495
 
316
496
  def safe_search(session)
317
- CatalogProducts.from(session.search_catalog(query: @query, limit: 10))
497
+ CatalogProducts.from(session.search_catalog(query: @query, limit: 10, context: buyer_context,
498
+ meta: agent_meta))
499
+ end
500
+
501
+ # A real store resolves which market — and so which inventory and
502
+ # prices — a call is scoped to from this (see
503
+ # Portage::Cli::BuyerContext). The loopback/stdio transports drop it.
504
+ def buyer_context
505
+ @buyer_context ||= BuyerContext.from_env
506
+ end
507
+
508
+ # Real UCP servers fetch this URL to verify the caller's identity
509
+ # before answering any call (see Transports::Http) — the own-store
510
+ # loopback path ignores it harmlessly, so it's cheapest to always pass
511
+ # it rather than branch on which transport `session` happens to be.
512
+ def agent_meta
513
+ { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
318
514
  end
319
515
 
320
516
  # --- Homepage fetch (used by both the manifest-not-found path and the
@@ -0,0 +1,42 @@
1
+ module Portage
2
+ module Cli
3
+ # Builds the UCP `context` object — the buyer locale hints a real store
4
+ # resolves a market from — out of the same `PORTAGE_SHIP_*` environment
5
+ # Portage::Cli::ShippingProfile reads, plus two of its own.
6
+ #
7
+ # Separate from ShippingProfile rather than a method on it because the two
8
+ # have opposite completeness rules. A shipping *address* is all-or-nothing:
9
+ # ShippingProfile returns nil unless every required field is set, since a
10
+ # half-filled address can't be submitted and this CLI never guesses at the
11
+ # missing half. A context is explicitly partial by design ("provisional
12
+ # context hints ... unsupported hints may be ignored without error"), and
13
+ # a country alone is enough to resolve a market, so sending what's known
14
+ # beats sending nothing.
15
+ #
16
+ # Sending nothing is the part that actually mattered: without a context,
17
+ # a live Shopify store builds a cart scoped to no market, drops every line
18
+ # item, and reports `merchandise_out_of_stock` for products its own
19
+ # `search_catalog` just returned as available (confirmed live 2026-09-22,
20
+ # see docs/ucp-tool-gating-investigation.md). So this returns `{}` rather
21
+ # than nil when nothing is configured — a caller passes it through either
22
+ # way, and `Transports::Http#with_context` omits an empty one from the
23
+ # wire.
24
+ module BuyerContext
25
+ ENV_VARS = {
26
+ address_country: "PORTAGE_SHIP_COUNTRY",
27
+ address_region: "PORTAGE_SHIP_REGION",
28
+ postal_code: "PORTAGE_SHIP_POSTAL_CODE",
29
+ currency: "PORTAGE_CURRENCY",
30
+ language: "PORTAGE_LANGUAGE"
31
+ }.freeze
32
+
33
+ # @return [Hash] context hints, empty when none are configured
34
+ def self.from_env
35
+ ENV_VARS.filter_map do |key, var|
36
+ value = ENV.fetch(var, nil)
37
+ [key, value] unless value.nil? || value.empty?
38
+ end.to_h
39
+ end
40
+ end
41
+ end
42
+ end
@@ -0,0 +1,82 @@
1
+ require "uri"
2
+ require_relative "config"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 1 of docs/plans/checkout-handoff-delivery.md — auto-opens a
7
+ # checkout_url in the shopper's browser when a `Buy` dead-end
8
+ # (escalation, permission denied, no payment token) hands off a link
9
+ # rather than completing the purchase itself.
10
+ #
11
+ # Default off. Precedence for the toggle (open decision #1, resolved):
12
+ # a per-invocation `auto_open:` override (portage buy --auto-open /
13
+ # --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which beats
14
+ # ~/.portage/config.json's "auto_open_checkout" (Config).
15
+ #
16
+ # No new gem for the actual open — every other shell-out in this repo
17
+ # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
18
+ # `system` rather than pulling in launchy for something the OS already
19
+ # provides. `system(cmd, url)` (array form, never an interpolated
20
+ # string) so a merchant-controlled checkout_url can't inject into a
21
+ # shell.
22
+ class CheckoutHandoff
23
+ ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
24
+ CONFIG_KEY = "auto_open_checkout".freeze
25
+ TRUE_VALUES = %w[1 true yes].freeze
26
+
27
+ def initialize(auto_open: nil, config: Config.load)
28
+ @override = auto_open
29
+ @config = config
30
+ end
31
+
32
+ def auto_open?
33
+ return @override unless @override.nil?
34
+
35
+ env = env_override
36
+ return env unless env.nil?
37
+
38
+ !!@config.get(CONFIG_KEY)
39
+ end
40
+
41
+ # @return [Boolean] whether the browser was actually opened.
42
+ def call(checkout_url)
43
+ return false unless auto_open? && https?(checkout_url)
44
+
45
+ open_browser(checkout_url)
46
+ end
47
+
48
+ private
49
+
50
+ def env_override
51
+ raw = ENV.fetch(ENV_VAR, nil)
52
+ return nil if raw.nil?
53
+
54
+ TRUE_VALUES.include?(raw.downcase)
55
+ end
56
+
57
+ def https?(url)
58
+ URI.parse(url).scheme == "https"
59
+ rescue URI::InvalidURIError
60
+ false
61
+ end
62
+
63
+ def open_browser(url)
64
+ command = platform_command
65
+ return false unless command
66
+
67
+ !!system(command, url)
68
+ rescue StandardError => e
69
+ warn "portage: couldn't open #{url} (#{e.message})"
70
+ false
71
+ end
72
+
73
+ def platform_command
74
+ case RbConfig::CONFIG["host_os"]
75
+ when /darwin/i then "open"
76
+ when /linux|bsd/i then "xdg-open"
77
+ when /mswin|mingw|cygwin/i then "start"
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,52 @@
1
+ require "json"
2
+ require "fileutils"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Durable, editable `portage-cli` config — `~/.portage/config.json` — for
7
+ # standing preferences that aren't payment/policy specific (the auto-open
8
+ # and notify-webhook toggles in docs/plans/checkout-handoff-delivery.md
9
+ # are the first two keys). Same shape as Portage::Ucp::Policy: absent
10
+ # file or absent key means "unset", a corrupt file raises rather than
11
+ # silently falling back.
12
+ class Config
13
+ PATH = File.join(Dir.home, ".portage", "config.json").freeze
14
+
15
+ def self.load(path: PATH) = new(path: path, data: read(path))
16
+
17
+ def initialize(path: PATH, data: {})
18
+ @path = path
19
+ @data = data
20
+ end
21
+
22
+ def get(key) = @data[key.to_s]
23
+
24
+ def set(key, value)
25
+ @data[key.to_s] = value
26
+ write
27
+ value
28
+ end
29
+
30
+ def to_h = @data.dup
31
+
32
+ def self.read(path)
33
+ return {} unless File.readable?(path)
34
+
35
+ raw = File.read(path)
36
+ return {} if raw.empty?
37
+
38
+ parsed = JSON.parse(raw)
39
+ parsed.is_a?(Hash) ? parsed : {}
40
+ end
41
+ private_class_method :read
42
+
43
+ private
44
+
45
+ def write
46
+ FileUtils.mkdir_p(File.dirname(@path))
47
+ File.write(@path, JSON.pretty_generate(@data))
48
+ File.chmod(0o600, @path)
49
+ end
50
+ end
51
+ end
52
+ 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,24 @@ 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,
144
+ context: BuyerContext.from_env, meta: agent_meta)
145
+ )
139
146
  products.filter_map { |product| offer(store, product) }
147
+ rescue Portage::Ucp::Client::MissingAgentProfileError
148
+ raise
140
149
  rescue StandardError
141
150
  []
142
151
  end
143
152
 
153
+ # Real UCP servers fetch this URL to verify the caller's identity
154
+ # before answering any call (see Transports::Http) — the own-store
155
+ # loopback path ignores it harmlessly.
156
+ def agent_meta
157
+ { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
158
+ end
159
+
144
160
  def offer(store, product)
145
161
  amount, currency = price_of(product)
146
162
  return nil if @max_price && amount && amount > @max_price
@@ -0,0 +1,202 @@
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
+ # The version here is deliberately *not* reused from
31
+ # `Portage::Ucp::Manifest::UCP_VERSION`, unlike an earlier revision of
32
+ # this class. Those two versions answer different questions: the
33
+ # manifest's says which spec revision *this gem's own server* implements
34
+ # for the businesses it serves, while an agent profile's says which
35
+ # revision the agent speaks to *whichever remote store it dials*. A
36
+ # store on a newer rollout than our server-side support (Shopify's
37
+ # `2026-08-25` endpoints, today) negotiates against the profile, so
38
+ # pinning the profile to the server's revision under-declared us.
39
+ #
40
+ # Rotation is the future-proofing this exists for: re-running with
41
+ # `rotate: true` keeps every key already published (so a request
42
+ # signed under an older `kid` keeps verifying while callers migrate)
43
+ # and adds one freshly generated key alongside them. Nothing here ever
44
+ # drops a key — retiring one is a deliberate, separate edit once
45
+ # nothing signs with it any more.
46
+ class AgentProfile
47
+ UCP_VERSION = "2026-08-25".freeze
48
+
49
+ SHOPPING_SERVICE = "dev.ucp.shopping".freeze
50
+
51
+ Key = Struct.new(:kid, :jwk, :private_pem, keyword_init: true)
52
+
53
+ # The capability identifiers a real UCP server resolves an incoming
54
+ # agent's tool registry from. These are NOT
55
+ # `Portage::Ucp::Capabilities::{CATALOG,CART,...}.name`, which an
56
+ # earlier revision of this class reused, and that reuse is what broke
57
+ # every live tool call for a month (see
58
+ # docs/ucp-tool-gating-investigation.md):
59
+ #
60
+ # - Catalog is registered per *action*
61
+ # (`dev.ucp.shopping.catalog.search`, `.catalog.lookup`), not as one
62
+ # coarse `dev.ucp.shopping.catalog`. A profile declaring only the
63
+ # coarse name resolves to zero catalog tools, and the server then
64
+ # answers `search_catalog` with `-32602 Tool not found:
65
+ # search_catalog` — despite `tools/list` having advertised it
66
+ # seconds earlier, and with no hint that the profile is the reason.
67
+ # `Portage::Ucp::Capabilities::CATALOG` keeps the coarse name
68
+ # because that's the right shape for *our own* server's manifest,
69
+ # where one Capability object owns all three actions; the two
70
+ # registries simply don't line up, so this document spells its own
71
+ # ids out rather than deriving them.
72
+ # - Cart/Checkout/Order are registered at the root name, so those do
73
+ # match — spelled out here anyway, so the whole declared set reads
74
+ # from one place.
75
+ # - Versions are spec revisions (`2026-08-25`), not the `"1"` that
76
+ # `Capability#version` carries.
77
+ #
78
+ # Live-verified 2026-09-22 against `catalog.shopify.com/api/ucp/mcp`
79
+ # and two per-shop endpoints: the granular ids answer `search_catalog`
80
+ # with real products at the anonymous tier (no token, no allowlist),
81
+ # the coarse id answers `Tool not found` on the same connection.
82
+ CAPABILITY_IDS = %w[
83
+ dev.ucp.shopping.catalog.search
84
+ dev.ucp.shopping.catalog.lookup
85
+ dev.ucp.shopping.cart
86
+ dev.ucp.shopping.checkout
87
+ dev.ucp.shopping.order
88
+ ].freeze
89
+
90
+ # @param out [String] path to write the public profile JSON document
91
+ # @param key_out [String] path to write the new private key's PEM —
92
+ # caller's responsibility to keep this out of version control
93
+ # @param rotate [Boolean] keep existing signing_keys from `out` (if
94
+ # it already exists) and add a new one, instead of replacing them
95
+ # @return [Hash] { profile_path:, private_key_path:, kid: } — the
96
+ # kid of the newly generated key
97
+ def self.generate(out:, key_out:, rotate: false)
98
+ new(out: out, key_out: key_out, rotate: rotate).generate
99
+ end
100
+
101
+ def initialize(out:, key_out:, rotate: false)
102
+ @out = out
103
+ @key_out = key_out
104
+ @rotate = rotate
105
+ end
106
+
107
+ def generate
108
+ new_key = generate_key
109
+ doc = build_document(carried_forward_keys + [new_key.jwk])
110
+
111
+ write_profile(doc)
112
+ write_private_key(new_key.private_pem)
113
+
114
+ { profile_path: @out, private_key_path: @key_out, kid: new_key.kid }
115
+ end
116
+
117
+ private
118
+
119
+ def carried_forward_keys
120
+ return [] unless @rotate && File.exist?(@out)
121
+
122
+ JSON.parse(File.read(@out)).fetch("signing_keys", [])
123
+ rescue JSON::ParserError
124
+ []
125
+ end
126
+
127
+ def build_document(signing_keys)
128
+ {
129
+ "ucp" => {
130
+ "version" => UCP_VERSION,
131
+ "services" => service_hash,
132
+ "capabilities" => capability_hash,
133
+ "payment_handlers" => {}
134
+ },
135
+ "signing_keys" => signing_keys
136
+ }
137
+ end
138
+
139
+ # Was `{}`. A server negotiating capabilities intersects its own
140
+ # service list with the profile's, so an empty `services` declares an
141
+ # agent that speaks no service at all — same class of under-declaration
142
+ # as the coarse capability ids above.
143
+ def service_hash
144
+ { SHOPPING_SERVICE => [{ "version" => UCP_VERSION,
145
+ "spec" => "https://ucp.dev/#{UCP_VERSION}/specification/overview",
146
+ "transport" => "mcp",
147
+ "schema" => "https://ucp.dev/#{UCP_VERSION}/services/shopping/mcp.openrpc.json" }] }
148
+ end
149
+
150
+ def capability_hash
151
+ CAPABILITY_IDS.to_h { |id| [id, [{ "version" => UCP_VERSION }]] }
152
+ end
153
+
154
+ def write_profile(doc)
155
+ FileUtils.mkdir_p(File.dirname(@out))
156
+ File.write(@out, "#{JSON.pretty_generate(doc)}\n")
157
+ end
158
+
159
+ def write_private_key(pem)
160
+ FileUtils.mkdir_p(File.dirname(@key_out))
161
+ File.write(@key_out, pem)
162
+ File.chmod(0o600, @key_out)
163
+ end
164
+
165
+ # A JWK's `kid` is derived from the key material itself (RFC 7638
166
+ # thumbprint) rather than assigned, so it can't drift from the key
167
+ # it names — a caller can't accidentally publish a profile where a
168
+ # `kid` points at the wrong entry.
169
+ def generate_key
170
+ pkey = OpenSSL::PKey::EC.generate("prime256v1")
171
+ x_b64, y_b64 = coordinates(pkey)
172
+ kid = thumbprint(x_b64, y_b64)
173
+
174
+ jwk = { "kid" => kid, "kty" => "EC", "crv" => "P-256", "x" => x_b64, "y" => y_b64,
175
+ "use" => "sig", "alg" => "ES256" }
176
+
177
+ Key.new(kid: kid, jwk: jwk, private_pem: pkey.to_pem)
178
+ end
179
+
180
+ # Raw uncompressed EC point encoding: 0x04 || x (32 bytes) || y (32
181
+ # bytes) for P-256 — see Portage::Ucp::Security::Signature::CURVES,
182
+ # which decodes the same layout in reverse when verifying.
183
+ def coordinates(pkey)
184
+ octets = pkey.public_key.to_bn.to_s(2)
185
+ coord_bytes = 32
186
+ x = octets[1, coord_bytes]
187
+ y = octets[1 + coord_bytes, coord_bytes]
188
+ [url_b64(x), url_b64(y)]
189
+ end
190
+
191
+ def thumbprint(x_b64, y_b64)
192
+ canonical = JSON.generate({ "crv" => "P-256", "kty" => "EC", "x" => x_b64, "y" => y_b64 })
193
+ url_b64(Digest::SHA256.digest(canonical))
194
+ end
195
+
196
+ def url_b64(bytes)
197
+ Base64.urlsafe_encode64(bytes, padding: false)
198
+ end
199
+ end
200
+ end
201
+ end
202
+ end
@@ -0,0 +1,77 @@
1
+ require "net/http"
2
+ require_relative "config"
3
+
4
+ module Portage
5
+ module Cli
6
+ # Phase 2 of docs/plans/checkout-handoff-delivery.md — POSTs a JSON body
7
+ # to a configured webhook when a `Buy` dead-end (escalation, permission
8
+ # denied, no payment token) hands off a checkout_url, so a caller can
9
+ # wire that into Slack/Zapier/their own relay. Same "the actual
10
+ # notification transport is the caller's job" posture as
11
+ # Portage::Ucp::Confirmer::Webhook's own comments — this class only ever
12
+ # speaks HTTP.
13
+ #
14
+ # Default off. Precedence for the webhook URL (open decision #1,
15
+ # resolved, same shape as CheckoutHandoff's auto-open toggle): a
16
+ # per-invocation `webhook_url:` override (portage buy --notify-webhook)
17
+ # beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
18
+ # "notify_webhook_url" (Config).
19
+ class Notifier
20
+ include Portage::Ucp::Support::HttpClient
21
+
22
+ ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
23
+ CONFIG_KEY = "notify_webhook_url".freeze
24
+
25
+ def initialize(webhook_url: nil, config: Config.load)
26
+ @override = webhook_url
27
+ @config = config
28
+ end
29
+
30
+ def webhook_url
31
+ return @override unless @override.nil?
32
+
33
+ env = ENV.fetch(ENV_VAR, nil)
34
+ return env unless env.nil? || env.empty?
35
+
36
+ @config.get(CONFIG_KEY)
37
+ end
38
+
39
+ def enabled? = !webhook_url.to_s.empty?
40
+
41
+ # Best-effort, matching CheckoutHandoff's posture: a failed POST never
42
+ # raises out of `Buy#call` — the checkout itself is a real, correct
43
+ # outcome independent of whether this delivery succeeded.
44
+ #
45
+ # @return [String, nil] the delivery failure message, or nil when
46
+ # disabled or on a successful POST.
47
+ def call(payload)
48
+ return nil unless enabled?
49
+
50
+ json_request(Net::HTTP::Post, webhook_url, body: payload)
51
+ nil
52
+ rescue StandardError => e
53
+ e.message
54
+ end
55
+
56
+ private
57
+
58
+ def api_error_class
59
+ NotifyApiError
60
+ end
61
+
62
+ # Raised (internally, always rescued by #call) when the webhook POST
63
+ # itself fails (non-2xx) — kept distinct from a plain network error
64
+ # only in that it carries the response body/status, same split
65
+ # Confirmer::WebhookApiError draws against a raw StandardError.
66
+ class NotifyApiError < Portage::Ucp::Error
67
+ include Portage::Ucp::Support::ApiError
68
+
69
+ private
70
+
71
+ def api_label
72
+ "Notifier"
73
+ end
74
+ end
75
+ end
76
+ end
77
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.5.1".freeze
3
+ VERSION = "0.6.4".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -3,6 +3,7 @@ require "json"
3
3
 
4
4
  require_relative "cli/version"
5
5
  require_relative "cli/shipping_profile"
6
+ require_relative "cli/buyer_context"
6
7
  require_relative "cli/catalog_products"
7
8
  require_relative "cli/buy"
8
9
  require_relative "cli/find"
@@ -11,6 +12,7 @@ require_relative "cli/history"
11
12
  require_relative "cli/payment_methods"
12
13
  require_relative "cli/doctor"
13
14
  require_relative "cli/generate/adapter"
15
+ require_relative "cli/generate/agent_profile"
14
16
 
15
17
  module Portage
16
18
  # `portage` — the single command-line entrypoint for acting as a shopper's
@@ -20,7 +22,8 @@ module Portage
20
22
  module Cli
21
23
  USAGE = <<~USAGE.freeze
22
24
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
23
- [--product-id ID] [--yes] [--dry-run] [--json]
25
+ [--product-id ID] [--yes] [--dry-run]
26
+ [--auto-open|--no-auto-open] [--notify-webhook URL] [--json]
24
27
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
25
28
  portage find --query "..." [--max-price N] [--limit N] [--json]
26
29
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -41,6 +44,7 @@ module Portage
41
44
  [--allow HOST ...] [--clear-allowlist]
42
45
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
43
46
  portage generate adapter NAME [--dir DIR]
47
+ portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
44
48
  USAGE
45
49
 
46
50
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
@@ -230,6 +234,8 @@ module Portage
230
234
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
231
235
  parser.on("--yes") { buy[:yes] = true }
232
236
  parser.on("--dry-run") { buy[:dry_run] = true }
237
+ parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
238
+ parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
233
239
  parser.on("--json") { parsed[:json] = true }
234
240
  add_search_options(parser, buy, parsed)
235
241
  end
@@ -552,8 +558,20 @@ module Portage
552
558
  # --- generate ---
553
559
 
554
560
  def self.run_generate(argv)
555
- kind, name, *rest = argv
556
- unless kind == "adapter" && name
561
+ kind, *rest = argv
562
+ case kind
563
+ when "adapter" then run_generate_adapter(rest)
564
+ when "agent-profile" then run_generate_agent_profile(rest)
565
+ else
566
+ warn USAGE
567
+ 1
568
+ end
569
+ end
570
+ private_class_method :run_generate
571
+
572
+ def self.run_generate_adapter(rest)
573
+ name, *rest = rest
574
+ unless name
557
575
  warn USAGE
558
576
  return 1
559
577
  end
@@ -564,7 +582,25 @@ module Portage
564
582
  puts "Scaffolded #{path}/"
565
583
  0
566
584
  end
567
- private_class_method :run_generate
585
+ private_class_method :run_generate_adapter
586
+
587
+ def self.run_generate_agent_profile(rest)
588
+ out = "agent-profile.json"
589
+ key_out = "agent-profile.key.pem"
590
+ rotate = false
591
+ OptionParser.new do |parser|
592
+ parser.on("--out FILE") { |v| out = v }
593
+ parser.on("--key-out FILE") { |v| key_out = v }
594
+ parser.on("--rotate") { rotate = true }
595
+ end.parse!(rest)
596
+
597
+ result = Generate::AgentProfile.generate(out: out, key_out: key_out, rotate: rotate)
598
+ puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
599
+ puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
600
+ "and off the machine that serves the public profile"
601
+ 0
602
+ end
603
+ private_class_method :run_generate_agent_profile
568
604
 
569
605
  # --- output ---
570
606
 
@@ -572,10 +608,18 @@ module Portage
572
608
  lines = ["#{report[:message]} (source: #{report[:source]})"]
573
609
  report[:products].each { |p| lines << " - #{product_line(p)}" }
574
610
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
611
+ lines.concat(format_handoff(report[:handoff])) if report[:handoff]
575
612
  lines.join("\n")
576
613
  end
577
614
  private_class_method :format_report
578
615
 
616
+ def self.format_handoff(handoff)
617
+ lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
618
+ lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
619
+ lines
620
+ end
621
+ private_class_method :format_handoff
622
+
579
623
  def self.product_line(product)
580
624
  product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
581
625
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.5.1
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-16 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: portage-ucp
@@ -16,28 +16,28 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.7'
19
+ version: '0.8'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.7'
26
+ version: '0.8'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: portage-ucp-client
29
29
  requirement: !ruby/object:Gem::Requirement
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.3'
33
+ version: '0.6'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.3'
40
+ version: '0.6'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: portage-ucp-journal
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -134,13 +134,18 @@ files:
134
134
  - exe/portage-console
135
135
  - lib/portage/cli.rb
136
136
  - lib/portage/cli/buy.rb
137
+ - lib/portage/cli/buyer_context.rb
137
138
  - lib/portage/cli/catalog_products.rb
139
+ - lib/portage/cli/checkout_handoff.rb
138
140
  - lib/portage/cli/compare.rb
141
+ - lib/portage/cli/config.rb
139
142
  - lib/portage/cli/console.rb
140
143
  - lib/portage/cli/doctor.rb
141
144
  - lib/portage/cli/find.rb
142
145
  - lib/portage/cli/generate/adapter.rb
146
+ - lib/portage/cli/generate/agent_profile.rb
143
147
  - lib/portage/cli/history.rb
148
+ - lib/portage/cli/notifier.rb
144
149
  - lib/portage/cli/payment_methods.rb
145
150
  - lib/portage/cli/payment_methods/env_backend.rb
146
151
  - lib/portage/cli/payment_methods/keychain_backend.rb