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 +4 -4
- data/CHANGELOG.md +112 -0
- data/lib/portage/cli/buy.rb +213 -17
- data/lib/portage/cli/buyer_context.rb +42 -0
- data/lib/portage/cli/checkout_handoff.rb +82 -0
- data/lib/portage/cli/config.rb +52 -0
- data/lib/portage/cli/find.rb +17 -1
- data/lib/portage/cli/generate/agent_profile.rb +202 -0
- data/lib/portage/cli/notifier.rb +77 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +48 -4
- metadata +11 -6
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 72d2d335f8f6d40abab7b9307f4e3dcfc21b2cb9dfc4fc5f67c490dd2d42e5ee
|
|
4
|
+
data.tar.gz: 98065dbee850dd5db2141731f45078526b00b877bbe67278b13c5b6ffa7a448e
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
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
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
|
123
|
-
|
|
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
|
-
|
|
160
|
-
|
|
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:
|
|
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
|
-
|
|
280
|
-
|
|
281
|
-
|
|
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
|
-
|
|
294
|
-
|
|
295
|
-
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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(
|
|
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
|
data/lib/portage/cli/version.rb
CHANGED
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]
|
|
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,
|
|
556
|
-
|
|
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 :
|
|
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.
|
|
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-
|
|
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.
|
|
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.
|
|
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.
|
|
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.
|
|
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
|