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 +4 -4
- data/CHANGELOG.md +79 -0
- data/lib/portage/cli/buy.rb +155 -15
- 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 +2 -1
- data/lib/portage/cli/generate/agent_profile.rb +66 -6
- data/lib/portage/cli/notifier.rb +77 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +13 -1
- metadata +13 -5
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,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`
|
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
|
|
@@ -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
|
-
|
|
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
|
|
155
|
-
|
|
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
|
-
|
|
328
|
-
|
|
329
|
-
|
|
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
|
-
|
|
342
|
-
|
|
343
|
-
|
|
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,
|
|
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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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,
|
|
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
|
-
#
|
|
31
|
-
#
|
|
32
|
-
# versions
|
|
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 =
|
|
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
|
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"
|
|
@@ -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]
|
|
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.
|
|
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:
|
|
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.
|
|
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.
|
|
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:
|
|
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: []
|