portage-cli 0.6.0 → 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: 4acb63ece20e24b7d43048315145a96e15ded1c4e5b0cf83c3f045603a033af6
4
- data.tar.gz: 536057c80badcd44ee1be90679fd7f9233adb3d5a1c07b5b11a12201fbae5701
3
+ metadata.gz: 72d2d335f8f6d40abab7b9307f4e3dcfc21b2cb9dfc4fc5f67c490dd2d42e5ee
4
+ data.tar.gz: 98065dbee850dd5db2141731f45078526b00b877bbe67278b13c5b6ffa7a448e
5
5
  SHA512:
6
- metadata.gz: 54bc26bf74fe96c2a2154062ab41619e10bf046da1cc06f067fc1f0f7eaec9c29ff6ab461e9cf2907ede4084805b8718f65557e5d4c3c79c486650eea10c1075
7
- data.tar.gz: deb45f093ce769ebae837bad5af628c8970aac76b6741ea8b083393b45038451a455ece2d603671c3d2884e3974858941ff6c29d96affd7bd3bbcc89f6a689d9
6
+ metadata.gz: 2ad81909c54bbf4ca6e588c8b3f72b19d8d7788d4762754b0f38783a51ce44dca60cd6e9a2aba7f05faeb0e7ca122bece589884194c44b982d7009615322432f
7
+ data.tar.gz: 81a8c3098e6dad475ec1138c44de866be4a349a3bc57a133330c6684b9924b050eebd44f1440292f2f28d0cd87bb1f6c3cbd356c85ccc4f769e7162dbff1803b
data/CHANGELOG.md CHANGED
@@ -4,6 +4,85 @@ 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
+
7
86
  ## [0.6.0] - 2026-09-17
8
87
 
9
88
  - Fixed `buy`/`find` crashing with a raw `Faraday::UnprocessableContentError`
@@ -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
@@ -80,7 +96,7 @@ module Portage
80
96
  catalog_only(session)
81
97
  end
82
98
  rescue Portage::Ucp::Client::MissingAgentProfileError, Portage::Ucp::Client::UnsupportedWireShapeError,
83
- MCP::Client::RequestHandlerError => e
99
+ Portage::Ucp::Client::ServerError, MCP::Client::RequestHandlerError => e
84
100
  native_flow_error_report(e)
85
101
  end
86
102
 
@@ -93,6 +109,8 @@ module Portage
93
109
  when Portage::Ucp::Client::UnsupportedWireShapeError
94
110
  build_report(source: "native_ucp", browse: true, checkout: false,
95
111
  message: "Can't complete checkout on #{@uri} yet: #{error.message}")
112
+ when Portage::Ucp::Client::ServerError
113
+ server_error_report(error)
96
114
  else
97
115
  # MCP::Client::RequestHandlerError doesn't retain the server's JSON
98
116
  # error body on this path, so this can't quote the server's own
@@ -105,6 +123,28 @@ module Portage
105
123
  end
106
124
  end
107
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
+ )
146
+ end
147
+
108
148
  def catalog_only(session)
109
149
  products = safe_search(session)
110
150
  report = build_report(
@@ -145,14 +185,29 @@ module Portage
145
185
  env = Portage::Ucp::Resolver.env_for(platform)
146
186
  return nil if Portage::Ucp::Resolver.missing_env(platform, env).any?
147
187
 
148
- 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)
149
203
  if adapter_supports_checkout?(adapter)
150
204
  full_buy(client_for(adapter), source: "adapter:#{platform.name}", fulfillment_adapter: adapter)
151
205
  else
152
206
  catalog_only_adapter(adapter, platform)
153
207
  end
154
- rescue LoadError, StandardError
155
- nil
208
+ rescue StandardError => e
209
+ build_report(source: "adapter:#{platform.name}", browse: false, checkout: false,
210
+ message: "#{platform.name} adapter error: #{e.message}")
156
211
  end
157
212
 
158
213
  def adapter_supports_checkout?(adapter)
@@ -213,7 +268,7 @@ module Portage
213
268
 
214
269
  checkout = session.create_checkout(line_items: [{ product_id: line_item_id_of(product), quantity: @qty }],
215
270
  fulfillment: requested_fulfillment(fulfillment_adapter),
216
- meta: agent_meta)
271
+ context: buyer_context, meta: agent_meta)
217
272
  checkout = select_cheapest_shipping(session, checkout) if fulfillment_adapter
218
273
  finish_checkout(session, source, products, checkout)
219
274
  end
@@ -324,13 +379,87 @@ module Portage
324
379
  def complete(session, source, products, checkout)
325
380
  @payment_token ||= PaymentMethods.default
326
381
  unless @payment_token
327
- return checkout_report(source, products, checkout,
328
- message: "No --payment-token given, and no default payment method on file — " \
329
- "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
+ )
330
390
  end
331
391
 
332
392
  completed = session.complete_checkout(checkout_id: checkout["id"], payment_token: @payment_token)
333
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)
334
463
  end
335
464
 
336
465
  def confirmed?
@@ -338,9 +467,12 @@ module Portage
338
467
  end
339
468
 
340
469
  def escalation_report(source, products, checkout)
341
- checkout_report(source, products, checkout,
342
- checkout_url: checkout["links"]&.find { |l| l["url"] }&.fetch("url", nil),
343
- 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
+ )
344
476
  end
345
477
 
346
478
  def dry_run_report(source, products, checkout)
@@ -355,14 +487,22 @@ module Portage
355
487
  # wants to see (id/status/totals) onto the report, rather than nesting
356
488
  # the raw hash under a key that'd collide with the boolean `checkout:`
357
489
  # field the output struct already reserves (§ output shape).
358
- def checkout_report(source, products, checkout, message:, checkout_url: nil)
490
+ def checkout_report(source, products, checkout, message:, checkout_url: nil, handoff: nil)
359
491
  build_report(source: source, browse: true, checkout: true, products: products, message: message,
360
492
  checkout_url: checkout_url, checkout_id: checkout["id"], checkout_status: checkout["status"],
361
- totals: checkout["totals"])
493
+ totals: checkout["totals"], handoff: handoff)
362
494
  end
363
495
 
364
496
  def safe_search(session)
365
- CatalogProducts.from(session.search_catalog(query: @query, limit: 10, meta: agent_meta))
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
366
506
  end
367
507
 
368
508
  # Real UCP servers fetch this URL to verify the caller's identity
@@ -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
@@ -140,7 +140,8 @@ module Portage
140
140
 
141
141
  def offers_for(store)
142
142
  products = CatalogProducts.from(
143
- store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS, meta: agent_meta)
143
+ store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS,
144
+ context: BuyerContext.from_env, meta: agent_meta)
144
145
  )
145
146
  products.filter_map { |product| offer(store, product) }
146
147
  rescue Portage::Ucp::Client::MissingAgentProfileError
@@ -27,9 +27,15 @@ module Portage
27
27
  # Same-looking key material, structurally different document — not
28
28
  # interchangeable, so this doesn't subclass or reuse Manifest.
29
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.
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.
33
39
  #
34
40
  # Rotation is the future-proofing this exists for: re-running with
35
41
  # `rotate: true` keeps every key already published (so a request
@@ -38,10 +44,49 @@ module Portage
38
44
  # drops a key — retiring one is a deliberate, separate edit once
39
45
  # nothing signs with it any more.
40
46
  class AgentProfile
41
- UCP_VERSION = Portage::Ucp::Manifest::UCP_VERSION
47
+ UCP_VERSION = "2026-08-25".freeze
48
+
49
+ SHOPPING_SERVICE = "dev.ucp.shopping".freeze
42
50
 
43
51
  Key = Struct.new(:kid, :jwk, :private_pem, keyword_init: true)
44
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
+
45
90
  # @param out [String] path to write the public profile JSON document
46
91
  # @param key_out [String] path to write the new private key's PEM —
47
92
  # caller's responsibility to keep this out of version control
@@ -83,14 +128,29 @@ module Portage
83
128
  {
84
129
  "ucp" => {
85
130
  "version" => UCP_VERSION,
86
- "services" => {},
87
- "capabilities" => {},
131
+ "services" => service_hash,
132
+ "capabilities" => capability_hash,
88
133
  "payment_handlers" => {}
89
134
  },
90
135
  "signing_keys" => signing_keys
91
136
  }
92
137
  end
93
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
+
94
154
  def write_profile(doc)
95
155
  FileUtils.mkdir_p(File.dirname(@out))
96
156
  File.write(@out, "#{JSON.pretty_generate(doc)}\n")
@@ -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.6.0".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"
@@ -21,7 +22,8 @@ module Portage
21
22
  module Cli
22
23
  USAGE = <<~USAGE.freeze
23
24
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
24
- [--product-id ID] [--yes] [--dry-run] [--json]
25
+ [--product-id ID] [--yes] [--dry-run]
26
+ [--auto-open|--no-auto-open] [--notify-webhook URL] [--json]
25
27
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
26
28
  portage find --query "..." [--max-price N] [--limit N] [--json]
27
29
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -232,6 +234,8 @@ module Portage
232
234
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
233
235
  parser.on("--yes") { buy[:yes] = true }
234
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 }
235
239
  parser.on("--json") { parsed[:json] = true }
236
240
  add_search_options(parser, buy, parsed)
237
241
  end
@@ -604,10 +608,18 @@ module Portage
604
608
  lines = ["#{report[:message]} (source: #{report[:source]})"]
605
609
  report[:products].each { |p| lines << " - #{product_line(p)}" }
606
610
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
611
+ lines.concat(format_handoff(report[:handoff])) if report[:handoff]
607
612
  lines.join("\n")
608
613
  end
609
614
  private_class_method :format_report
610
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
+
611
623
  def self.product_line(product)
612
624
  product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
613
625
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.6.4
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-22 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: portage-ucp
@@ -29,14 +30,14 @@ dependencies:
29
30
  requirements:
30
31
  - - "~>"
31
32
  - !ruby/object:Gem::Version
32
- version: '0.4'
33
+ version: '0.6'
33
34
  type: :runtime
34
35
  prerelease: false
35
36
  version_requirements: !ruby/object:Gem::Requirement
36
37
  requirements:
37
38
  - - "~>"
38
39
  - !ruby/object:Gem::Version
39
- version: '0.4'
40
+ version: '0.6'
40
41
  - !ruby/object:Gem::Dependency
41
42
  name: portage-ucp-journal
42
43
  requirement: !ruby/object:Gem::Requirement
@@ -119,6 +120,7 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
119
120
  portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
120
121
  read-only view of local purchase/transaction/order state); no single adapter gem
121
122
  is a hard dependency.'
123
+ email:
122
124
  executables:
123
125
  - portage
124
126
  - portage-console
@@ -132,14 +134,18 @@ files:
132
134
  - exe/portage-console
133
135
  - lib/portage/cli.rb
134
136
  - lib/portage/cli/buy.rb
137
+ - lib/portage/cli/buyer_context.rb
135
138
  - lib/portage/cli/catalog_products.rb
139
+ - lib/portage/cli/checkout_handoff.rb
136
140
  - lib/portage/cli/compare.rb
141
+ - lib/portage/cli/config.rb
137
142
  - lib/portage/cli/console.rb
138
143
  - lib/portage/cli/doctor.rb
139
144
  - lib/portage/cli/find.rb
140
145
  - lib/portage/cli/generate/adapter.rb
141
146
  - lib/portage/cli/generate/agent_profile.rb
142
147
  - lib/portage/cli/history.rb
148
+ - lib/portage/cli/notifier.rb
143
149
  - lib/portage/cli/payment_methods.rb
144
150
  - lib/portage/cli/payment_methods/env_backend.rb
145
151
  - lib/portage/cli/payment_methods/keychain_backend.rb
@@ -155,6 +161,7 @@ metadata:
155
161
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
156
162
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
157
163
  rubygems_mfa_required: 'true'
164
+ post_install_message:
158
165
  rdoc_options: []
159
166
  require_paths:
160
167
  - lib
@@ -169,7 +176,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
169
176
  - !ruby/object:Gem::Version
170
177
  version: '0'
171
178
  requirements: []
172
- rubygems_version: 4.0.21
179
+ rubygems_version: 3.5.22
180
+ signing_key:
173
181
  specification_version: 4
174
182
  summary: portage — one CLI command to buy from any store, native UCP or not
175
183
  test_files: []