portage-cli 0.6.0 → 0.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +188 -0
- data/README.md +117 -6
- data/lib/portage/cli/buy.rb +467 -48
- data/lib/portage/cli/buyer_context.rb +42 -0
- data/lib/portage/cli/checkout_handoff.rb +70 -0
- data/lib/portage/cli/confidence_check.rb +127 -0
- data/lib/portage/cli/config.rb +52 -0
- data/lib/portage/cli/decisions.rb +72 -0
- data/lib/portage/cli/doctor.rb +17 -0
- data/lib/portage/cli/find.rb +10 -9
- data/lib/portage/cli/generate/agent_profile.rb +66 -6
- data/lib/portage/cli/history.rb +23 -6
- data/lib/portage/cli/notifier.rb +73 -0
- data/lib/portage/cli/setting.rb +44 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +145 -19
- metadata +27 -8
|
@@ -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,70 @@
|
|
|
1
|
+
require "uri"
|
|
2
|
+
require_relative "config"
|
|
3
|
+
require_relative "setting"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# Phase 1 of docs/plans/checkout-handoff-delivery.md — auto-opens a
|
|
8
|
+
# checkout_url in the shopper's browser when a `Buy` dead-end
|
|
9
|
+
# (escalation, permission denied, no payment token) hands off a link
|
|
10
|
+
# rather than completing the purchase itself.
|
|
11
|
+
#
|
|
12
|
+
# Default off. Precedence for the toggle (open decision #1, resolved,
|
|
13
|
+
# see Setting): a per-invocation `auto_open:` override (portage buy
|
|
14
|
+
# --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
|
|
15
|
+
# beats ~/.portage/config.json's "auto_open_checkout" (Config).
|
|
16
|
+
#
|
|
17
|
+
# No new gem for the actual open — every other shell-out in this repo
|
|
18
|
+
# (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
|
|
19
|
+
# `system` rather than pulling in launchy for something the OS already
|
|
20
|
+
# provides. `system(cmd, url)` (array form, never an interpolated
|
|
21
|
+
# string) so a merchant-controlled checkout_url can't inject into a
|
|
22
|
+
# shell.
|
|
23
|
+
class CheckoutHandoff
|
|
24
|
+
ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
|
|
25
|
+
CONFIG_KEY = "auto_open_checkout".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
|
+
Setting.flag?(override: @override, env: ENV_VAR, config: @config, config_key: CONFIG_KEY)
|
|
34
|
+
end
|
|
35
|
+
|
|
36
|
+
# @return [Boolean] whether the browser was actually opened.
|
|
37
|
+
def call(checkout_url)
|
|
38
|
+
return false unless auto_open? && https?(checkout_url)
|
|
39
|
+
|
|
40
|
+
open_browser(checkout_url)
|
|
41
|
+
end
|
|
42
|
+
|
|
43
|
+
private
|
|
44
|
+
|
|
45
|
+
def https?(url)
|
|
46
|
+
URI.parse(url).scheme == "https"
|
|
47
|
+
rescue URI::InvalidURIError
|
|
48
|
+
false
|
|
49
|
+
end
|
|
50
|
+
|
|
51
|
+
def open_browser(url)
|
|
52
|
+
command = platform_command
|
|
53
|
+
return false unless command
|
|
54
|
+
|
|
55
|
+
!!system(command, url)
|
|
56
|
+
rescue StandardError => e
|
|
57
|
+
warn "portage: couldn't open #{url} (#{e.message})"
|
|
58
|
+
false
|
|
59
|
+
end
|
|
60
|
+
|
|
61
|
+
def platform_command
|
|
62
|
+
case RbConfig::CONFIG["host_os"]
|
|
63
|
+
when /darwin/i then "open"
|
|
64
|
+
when /linux|bsd/i then "xdg-open"
|
|
65
|
+
when /mswin|mingw|cygwin/i then "start"
|
|
66
|
+
end
|
|
67
|
+
end
|
|
68
|
+
end
|
|
69
|
+
end
|
|
70
|
+
end
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
require "json"
|
|
2
|
+
require_relative "setting"
|
|
3
|
+
require_relative "decisions"
|
|
4
|
+
|
|
5
|
+
module Portage
|
|
6
|
+
module Cli
|
|
7
|
+
# The confidence gate (docs/plans/system-one-decision-layer.md
|
|
8
|
+
# § Responsibilities 3) as `portage buy` uses it: one yes/no question put
|
|
9
|
+
# to a Decision::ModelBackends backend right before an unattended
|
|
10
|
+
# (`--yes`) completion — "is this checkout what the shopper asked for,
|
|
11
|
+
# and safe to complete without a person looking at it?"
|
|
12
|
+
#
|
|
13
|
+
# Default off. Nothing asks a model anything unless a backend is named,
|
|
14
|
+
# via `--decision-backend NAME` or PORTAGE_DECISION_BACKEND (`jev` or
|
|
15
|
+
# `laya`, Decision::ModelBackends::REGISTRY's keys). The threshold comes
|
|
16
|
+
# from `--min-confidence N` or PORTAGE_MIN_CONFIDENCE, else
|
|
17
|
+
# DEFAULT_THRESHOLD.
|
|
18
|
+
#
|
|
19
|
+
# This answers the plan's open question about how the gate relates to
|
|
20
|
+
# `Confirmer`: it runs in front of the completion, after `--yes` and the
|
|
21
|
+
# local PolicyCheck, and never in place of either. A low score holds the
|
|
22
|
+
# purchase and hands the checkout to the shopper. It never
|
|
23
|
+
# auto-approves anything `--yes` wouldn't already have allowed.
|
|
24
|
+
#
|
|
25
|
+
# Fails closed. An unknown backend name, a backend that isn't configured
|
|
26
|
+
# (no JEV_API_KEY, no Laya bridge), or a backend call that fails all hold
|
|
27
|
+
# the purchase, the same as a low score does. So does naming a backend
|
|
28
|
+
# without portage-ucp-decision installed: the backends live in that
|
|
29
|
+
# optional gem. Once a caller has asked for a confidence check, the
|
|
30
|
+
# check never passing is not "no opinion".
|
|
31
|
+
class ConfidenceCheck
|
|
32
|
+
BACKEND_ENV = "PORTAGE_DECISION_BACKEND".freeze
|
|
33
|
+
THRESHOLD_ENV = "PORTAGE_MIN_CONFIDENCE".freeze
|
|
34
|
+
DEFAULT_THRESHOLD = 0.8
|
|
35
|
+
QUESTION = "safe_to_complete".freeze
|
|
36
|
+
# Phrased so "yes" means "proceed": a noul answer's value is the
|
|
37
|
+
# probability of yes, and that is what ConfidenceGate thresholds.
|
|
38
|
+
INSTRUCTIONS = "The state is a checkout an agent built on a shopper's behalf: the shopper's search " \
|
|
39
|
+
"query, the merchant, the requested quantity, the checkout's line items and totals, and " \
|
|
40
|
+
"any warnings about where the checkout differs from the request. Answer yes only if the " \
|
|
41
|
+
"checkout clearly matches what the shopper asked for and is safe to complete without a " \
|
|
42
|
+
"person reviewing it first.".freeze
|
|
43
|
+
|
|
44
|
+
# @param backend [String, nil] a ModelBackends::REGISTRY key; nil
|
|
45
|
+
# defers to PORTAGE_DECISION_BACKEND.
|
|
46
|
+
# @param threshold [Float, nil] 0.0..1.0; nil defers to
|
|
47
|
+
# PORTAGE_MIN_CONFIDENCE, then DEFAULT_THRESHOLD.
|
|
48
|
+
# @param resolver [#call, nil] builds a backend from its name —
|
|
49
|
+
# injectable so specs never reach a real model. nil means
|
|
50
|
+
# ModelBackends.resolve, looked up only once a backend is needed.
|
|
51
|
+
# @raise [ArgumentError] for a threshold outside 0.0..1.0. An explicit
|
|
52
|
+
# `threshold:` is always checked, so a mistyped `--min-confidence`
|
|
53
|
+
# fails loudly. PORTAGE_MIN_CONFIDENCE is read, and so checked,
|
|
54
|
+
# only once a backend is enabled: a stale value in a shell that
|
|
55
|
+
# never uses the gate mustn't stop every `portage buy`.
|
|
56
|
+
def initialize(backend: nil, threshold: nil, resolver: nil)
|
|
57
|
+
@backend_name = Setting.resolve(override: backend, env: BACKEND_ENV)&.to_s&.strip
|
|
58
|
+
raw_threshold = enabled? ? Setting.resolve(override: threshold, env: THRESHOLD_ENV) : threshold
|
|
59
|
+
@threshold = parse_threshold(raw_threshold || DEFAULT_THRESHOLD)
|
|
60
|
+
@resolver = resolver
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
attr_reader :threshold
|
|
64
|
+
|
|
65
|
+
def enabled? = !@backend_name.nil?
|
|
66
|
+
|
|
67
|
+
# @param state [Hash] JSON-serializable. Never pass it a payment token.
|
|
68
|
+
# @return [Hash, nil] nil when disabled. Otherwise `proceed:`,
|
|
69
|
+
# `reason:`, `confidence:`, `threshold:`, `backend:` and `error:`.
|
|
70
|
+
# `reason` is nil when it proceeds, else why it held:
|
|
71
|
+
# `below_threshold`, `backend_error` (unknown, unconfigured or
|
|
72
|
+
# failed backend) or `not_installed` (no portage-ucp-decision).
|
|
73
|
+
# `error` is the detail behind the last two, nil otherwise.
|
|
74
|
+
def call(state)
|
|
75
|
+
return nil unless enabled?
|
|
76
|
+
return held("not_installed", not_installed_message) unless Decisions.available?
|
|
77
|
+
|
|
78
|
+
verdict = Portage::Ucp::Decision::ConfidenceGate.via_backend(
|
|
79
|
+
backend: resolve_backend, state: JSON.generate(state), question: QUESTION,
|
|
80
|
+
instructions: INSTRUCTIONS, threshold: @threshold
|
|
81
|
+
)
|
|
82
|
+
{ proceed: verdict.proceed, reason: verdict.proceed ? nil : "below_threshold",
|
|
83
|
+
confidence: verdict.confidence, threshold: @threshold, backend: @backend_name, error: nil }
|
|
84
|
+
rescue StandardError => e
|
|
85
|
+
# Any failure, not only Decision::Error: this runs after a real
|
|
86
|
+
# checkout exists, so an escaped exception would drop the report,
|
|
87
|
+
# the hand-off and the history entry along with the purchase.
|
|
88
|
+
held("backend_error", e.message)
|
|
89
|
+
end
|
|
90
|
+
|
|
91
|
+
# What `portage doctor` reports: why the named backend would hold every
|
|
92
|
+
# `--yes` purchase, before one is attempted.
|
|
93
|
+
# @return [String, nil] nil when disabled or ready to answer.
|
|
94
|
+
def configuration_problem
|
|
95
|
+
return nil unless enabled?
|
|
96
|
+
return not_installed_message unless Decisions.available?
|
|
97
|
+
|
|
98
|
+
resolve_backend.configuration_problem
|
|
99
|
+
rescue Portage::Ucp::Decision::Error => e
|
|
100
|
+
e.message
|
|
101
|
+
end
|
|
102
|
+
|
|
103
|
+
private
|
|
104
|
+
|
|
105
|
+
def resolve_backend
|
|
106
|
+
(@resolver || Portage::Ucp::Decision::ModelBackends.method(:resolve)).call(@backend_name)
|
|
107
|
+
end
|
|
108
|
+
|
|
109
|
+
def held(reason, error)
|
|
110
|
+
{ proceed: false, reason: reason, confidence: nil, threshold: @threshold, backend: @backend_name,
|
|
111
|
+
error: error }
|
|
112
|
+
end
|
|
113
|
+
|
|
114
|
+
def not_installed_message
|
|
115
|
+
"portage-ucp-decision is not installed — `gem install portage-ucp-decision` to use the " \
|
|
116
|
+
"#{@backend_name} confidence check."
|
|
117
|
+
end
|
|
118
|
+
|
|
119
|
+
def parse_threshold(raw)
|
|
120
|
+
value = Float(raw, exception: false)
|
|
121
|
+
return value if value&.between?(0.0, 1.0)
|
|
122
|
+
|
|
123
|
+
raise ArgumentError, "confidence threshold must be a number between 0.0 and 1.0, got #{raw.inspect}"
|
|
124
|
+
end
|
|
125
|
+
end
|
|
126
|
+
end
|
|
127
|
+
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
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
require "portage/ucp"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# portage-cli's judgment calls (docs/plans/system-one-decision-layer.md).
|
|
6
|
+
# The rules live in portage-ucp core: Support::OfferRanking,
|
|
7
|
+
# Support::Escalation and PolicyGuard. portage-ucp-decision's
|
|
8
|
+
# OfferRanking, EscalationPolicy and PolicyCheck are typed wrappers
|
|
9
|
+
# around those same modules, so `buy` and `find` answer the same way
|
|
10
|
+
# whether or not that gem is installed.
|
|
11
|
+
#
|
|
12
|
+
# Every method returns a plain Hash whose `reason` is a String or nil,
|
|
13
|
+
# so a caller sees the same values in-process as in `--json`.
|
|
14
|
+
#
|
|
15
|
+
# Only the confidence gate needs the optional gem, because the model
|
|
16
|
+
# backends live there (see ConfidenceCheck and .available?).
|
|
17
|
+
module Decisions
|
|
18
|
+
# Loaded the way Resolver.build_adapter's callers treat an optional
|
|
19
|
+
# adapter gem: `require`, then rescue LoadError. It isn't in the
|
|
20
|
+
# gemspec, so `gem install portage-cli` stays light.
|
|
21
|
+
# @return [Boolean] whether portage-ucp-decision could be loaded.
|
|
22
|
+
# Memoized: `require` runs once per process.
|
|
23
|
+
def self.available?
|
|
24
|
+
return @available unless @available.nil?
|
|
25
|
+
|
|
26
|
+
@available = begin
|
|
27
|
+
require "portage/ucp/decision"
|
|
28
|
+
true
|
|
29
|
+
rescue LoadError
|
|
30
|
+
false
|
|
31
|
+
end
|
|
32
|
+
end
|
|
33
|
+
|
|
34
|
+
# @param offers [Array<Hash>] with `:checkout` (buyable) and `:amount`.
|
|
35
|
+
def self.rank(offers)
|
|
36
|
+
Portage::Ucp::Support::OfferRanking.rank(offers) { |offer| [offer[:checkout], offer[:amount]] }
|
|
37
|
+
end
|
|
38
|
+
|
|
39
|
+
# @param warnings [Array<String>] mismatches that should escalate —
|
|
40
|
+
# pass none when mismatches are only to be reported.
|
|
41
|
+
# @return [Hash] `escalate:`, `reason:`.
|
|
42
|
+
def self.escalation(checkout_status:, warnings: [])
|
|
43
|
+
reason = Portage::Ucp::Support::Escalation.reason(checkout_status: checkout_status, warnings: warnings)
|
|
44
|
+
{ escalate: !reason.nil?, reason: reason&.to_s }
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
# The buyer's spend policy, checked by PolicyGuard.
|
|
48
|
+
#
|
|
49
|
+
# PolicyGuard skips both spend caps when `amount` is nil, which suits
|
|
50
|
+
# its own caller (an adapter that can't price a checkout up front).
|
|
51
|
+
# Here it would let a checkout with no `total` line past a configured
|
|
52
|
+
# cap while reporting `allowed: true`, so a missing total is denied as
|
|
53
|
+
# `total_unknown` whenever a cap exists.
|
|
54
|
+
# @param transaction_log [Portage::Ucp::Support::TransactionLog] what
|
|
55
|
+
# the rolling cap and velocity limit count.
|
|
56
|
+
# @return [Hash] `allowed:`, `reason:`.
|
|
57
|
+
def self.policy(amount:, currency:, merchant:, token_ref:,
|
|
58
|
+
transaction_log: Portage::Ucp::Support::TransactionLog.new)
|
|
59
|
+
policy = Portage::Ucp::Policy.load
|
|
60
|
+
if amount.nil? && (policy.per_transaction_cap || policy.rolling_cap)
|
|
61
|
+
return { allowed: false, reason: "total_unknown" }
|
|
62
|
+
end
|
|
63
|
+
|
|
64
|
+
Portage::Ucp::PolicyGuard.check!(amount: amount, currency: currency, merchant: merchant,
|
|
65
|
+
token_ref: token_ref, policy: policy, transaction_log: transaction_log)
|
|
66
|
+
{ allowed: true, reason: nil }
|
|
67
|
+
rescue Portage::Ucp::PolicyViolationError => e
|
|
68
|
+
{ allowed: false, reason: e.reason.to_s }
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "portage/ucp"
|
|
2
|
+
require_relative "confidence_check"
|
|
2
3
|
|
|
3
4
|
module Portage
|
|
4
5
|
module Cli
|
|
@@ -24,6 +25,7 @@ module Portage
|
|
|
24
25
|
rate_limiter_finding,
|
|
25
26
|
signing_keys_finding,
|
|
26
27
|
payment_handlers_finding,
|
|
28
|
+
decision_backend_finding,
|
|
27
29
|
*capability_findings
|
|
28
30
|
].compact
|
|
29
31
|
end
|
|
@@ -54,6 +56,21 @@ module Portage
|
|
|
54
56
|
Finding.new(check: "signing_keys", message: "No signing_keys configured — the manifest ships unsigned.")
|
|
55
57
|
end
|
|
56
58
|
|
|
59
|
+
# The confidence gate's backend, checked only once one is selected
|
|
60
|
+
# (PORTAGE_DECISION_BACKEND): the gate is off by default, and a
|
|
61
|
+
# missing key for a backend nobody chose is noise. Once one is
|
|
62
|
+
# selected, anything short of ready holds every `--yes` purchase, so
|
|
63
|
+
# it's flagged here rather than first surfacing mid-checkout. Covers
|
|
64
|
+
# the gem not being installed, an unknown backend name, a missing
|
|
65
|
+
# JEV_API_KEY, a missing Laya bridge, and a bad PORTAGE_MIN_CONFIDENCE.
|
|
66
|
+
def decision_backend_finding
|
|
67
|
+
problem = ConfidenceCheck.new.configuration_problem
|
|
68
|
+
problem && Finding.new(check: "decision_backend",
|
|
69
|
+
message: "#{problem} — until then every `portage buy --yes` is held for the shopper.")
|
|
70
|
+
rescue ArgumentError => e
|
|
71
|
+
Finding.new(check: "decision_backend", message: e.message)
|
|
72
|
+
end
|
|
73
|
+
|
|
57
74
|
def payment_handlers_finding
|
|
58
75
|
return unless Array(config.payment_handlers).empty?
|
|
59
76
|
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -4,6 +4,7 @@ require "portage/ucp/client"
|
|
|
4
4
|
|
|
5
5
|
require_relative "search_backends"
|
|
6
6
|
require_relative "probe_cache"
|
|
7
|
+
require_relative "decisions"
|
|
7
8
|
|
|
8
9
|
module Portage
|
|
9
10
|
module Cli
|
|
@@ -140,7 +141,8 @@ module Portage
|
|
|
140
141
|
|
|
141
142
|
def offers_for(store)
|
|
142
143
|
products = CatalogProducts.from(
|
|
143
|
-
store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS,
|
|
144
|
+
store[:session].search_catalog(query: @query, limit: PER_STORE_RESULTS,
|
|
145
|
+
context: BuyerContext.from_env, meta: agent_meta)
|
|
144
146
|
)
|
|
145
147
|
products.filter_map { |product| offer(store, product) }
|
|
146
148
|
rescue Portage::Ucp::Client::MissingAgentProfileError
|
|
@@ -165,14 +167,13 @@ module Portage
|
|
|
165
167
|
amount: amount, currency: currency, url: field(product, "url") }
|
|
166
168
|
end
|
|
167
169
|
|
|
168
|
-
# Buyable first, then cheapest, then unpriced
|
|
169
|
-
#
|
|
170
|
-
#
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
end
|
|
170
|
+
# Buyable first, then cheapest, then unpriced (see Decisions.rank —
|
|
171
|
+
# core's Support::OfferRanking, the rule portage-ucp-decision's
|
|
172
|
+
# OfferRanking wraps), so an agent loop ranking its own candidate list
|
|
173
|
+
# gets the same order this command prints. Sorting on price alone
|
|
174
|
+
# would float a browse-only store above one you can actually check
|
|
175
|
+
# out from, which is the wrong answer to "buy me this".
|
|
176
|
+
def rank(offers) = Decisions.rank(offers)
|
|
176
177
|
|
|
177
178
|
# --- Shapes ---
|
|
178
179
|
|
|
@@ -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")
|
data/lib/portage/cli/history.rb
CHANGED
|
@@ -9,6 +9,13 @@ module Portage
|
|
|
9
9
|
# history list` can answer "what did I already look for" and "what did I
|
|
10
10
|
# already buy" without re-running anything, and `portage history clear`
|
|
11
11
|
# can wipe either or both.
|
|
12
|
+
#
|
|
13
|
+
# A purchase entry is one checkout `portage buy` created, whatever came
|
|
14
|
+
# of it: `outcome` is the report's own (`purchased`, `dry_run`,
|
|
15
|
+
# `policy_blocked`, ...), so "what did I already buy" is the entries
|
|
16
|
+
# whose outcome is `purchased`, and every other entry still carries the
|
|
17
|
+
# `checkout_url` to finish it by hand. Entries written before `outcome`
|
|
18
|
+
# existed have only `checkout_status` and `message`.
|
|
12
19
|
class History
|
|
13
20
|
PATH = File.join(Dir.home, ".portage", "history.json").freeze
|
|
14
21
|
MAX_ENTRIES = 200
|
|
@@ -18,14 +25,24 @@ module Portage
|
|
|
18
25
|
@now = now.to_i
|
|
19
26
|
end
|
|
20
27
|
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
28
|
+
# @param items [Array<Hash>] what the checkout holds (`id`, `title`,
|
|
29
|
+
# `quantity`), not the search results it was picked from.
|
|
30
|
+
# @param total [Integer, nil] minor units of `currency`.
|
|
31
|
+
# rubocop:disable Metrics/ParameterLists -- all keywords; one entry's fields
|
|
32
|
+
def record_purchase(url:, query:, outcome:, message:, source: nil, checkout_id: nil, checkout_status: nil,
|
|
33
|
+
checkout_url: nil, total: nil, currency: nil, items: [])
|
|
34
|
+
# rubocop:enable Metrics/ParameterLists
|
|
35
|
+
append("purchases", { "url" => url, "query" => query, "outcome" => outcome, "source" => source,
|
|
36
|
+
"checkout_id" => checkout_id, "checkout_status" => checkout_status,
|
|
37
|
+
"checkout_url" => checkout_url, "total" => total, "currency" => currency,
|
|
38
|
+
"items" => items, "message" => message, "at" => @now })
|
|
25
39
|
end
|
|
26
40
|
|
|
27
|
-
|
|
28
|
-
|
|
41
|
+
# @param url [String, nil] the store, for a `portage buy` that never
|
|
42
|
+
# reached a checkout there; nil for a cross-store `portage find`.
|
|
43
|
+
def record_search(query:, offer_count:, message:, url: nil)
|
|
44
|
+
append("searches", { "query" => query, "url" => url, "offer_count" => offer_count, "message" => message,
|
|
45
|
+
"at" => @now }.compact)
|
|
29
46
|
end
|
|
30
47
|
|
|
31
48
|
def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
require "net/http"
|
|
2
|
+
require "json"
|
|
3
|
+
require "uri"
|
|
4
|
+
require_relative "config"
|
|
5
|
+
require_relative "setting"
|
|
6
|
+
|
|
7
|
+
module Portage
|
|
8
|
+
module Cli
|
|
9
|
+
# Phase 2 of docs/plans/checkout-handoff-delivery.md — POSTs a JSON body
|
|
10
|
+
# to a configured webhook when a `Buy` dead-end (escalation, permission
|
|
11
|
+
# denied, no payment token) hands off a checkout_url, so a caller can
|
|
12
|
+
# wire that into Slack/Zapier/their own relay. Same "the actual
|
|
13
|
+
# notification transport is the caller's job" posture as
|
|
14
|
+
# Portage::Ucp::Confirmer::Webhook's own comments — this class only ever
|
|
15
|
+
# speaks HTTP.
|
|
16
|
+
#
|
|
17
|
+
# Default off. Precedence for the webhook URL (open decision #1,
|
|
18
|
+
# resolved, same Setting as CheckoutHandoff's auto-open toggle): a
|
|
19
|
+
# per-invocation `webhook_url:` override (portage buy --notify-webhook)
|
|
20
|
+
# beats PORTAGE_NOTIFY_WEBHOOK_URL, which beats ~/.portage/config.json's
|
|
21
|
+
# "notify_webhook_url" (Config).
|
|
22
|
+
class Notifier
|
|
23
|
+
ENV_VAR = "PORTAGE_NOTIFY_WEBHOOK_URL".freeze
|
|
24
|
+
CONFIG_KEY = "notify_webhook_url".freeze
|
|
25
|
+
TIMEOUT = 5
|
|
26
|
+
# Enough of an error body to name the problem, without pasting a whole
|
|
27
|
+
# HTML error page into the report.
|
|
28
|
+
BODY_EXCERPT = 200
|
|
29
|
+
|
|
30
|
+
def initialize(webhook_url: nil, config: Config.load)
|
|
31
|
+
@override = webhook_url
|
|
32
|
+
@config = config
|
|
33
|
+
end
|
|
34
|
+
|
|
35
|
+
def webhook_url
|
|
36
|
+
Setting.resolve(override: @override, env: ENV_VAR, config: @config, config_key: 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
|
+
# Success is any 2xx, whatever the body: Slack's incoming webhooks
|
|
46
|
+
# answer `ok` as plain text, which a JSON parse would have misreported
|
|
47
|
+
# as a failed delivery. Short timeouts, since a hand-off is waiting on
|
|
48
|
+
# this and Net::HTTP's defaults would stall it for up to two minutes.
|
|
49
|
+
#
|
|
50
|
+
# @return [String, nil] the delivery failure message, or nil when
|
|
51
|
+
# disabled or on a successful POST.
|
|
52
|
+
def call(payload)
|
|
53
|
+
return nil unless enabled?
|
|
54
|
+
|
|
55
|
+
response = post(URI(webhook_url), JSON.generate(payload))
|
|
56
|
+
return nil if response.is_a?(Net::HTTPSuccess)
|
|
57
|
+
|
|
58
|
+
"webhook answered #{response.code}: #{response.body.to_s[0, BODY_EXCERPT]}"
|
|
59
|
+
rescue StandardError => e
|
|
60
|
+
"webhook POST failed: #{e.message}"
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def post(uri, body)
|
|
66
|
+
Net::HTTP.start(uri.host, uri.port, use_ssl: uri.scheme == "https",
|
|
67
|
+
open_timeout: TIMEOUT, read_timeout: TIMEOUT) do |http|
|
|
68
|
+
http.post(uri.request_uri, body, "Content-Type" => "application/json")
|
|
69
|
+
end
|
|
70
|
+
end
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|