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.
@@ -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
@@ -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
 
@@ -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, meta: agent_meta)
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. Sorting on price alone
169
- # would float a browse-only store above one you can actually check out
170
- # from, which is the wrong answer to "buy me this".
171
- def rank(offers)
172
- offers.sort_by do |offer|
173
- [offer[:checkout] ? 0 : 1, offer[:amount] ? 0 : 1, offer[:amount] || 0]
174
- end
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
- # `Portage::Ucp::Manifest::UCP_VERSION` is reused as-is (not
31
- # redeclared) so the two documents can never drift to different spec
32
- # versions by accident.
30
+ # The version here is deliberately *not* reused from
31
+ # `Portage::Ucp::Manifest::UCP_VERSION`, unlike an earlier revision of
32
+ # this class. Those two versions answer different questions: the
33
+ # manifest's says which spec revision *this gem's own server* implements
34
+ # for the businesses it serves, while an agent profile's says which
35
+ # revision the agent speaks to *whichever remote store it dials*. A
36
+ # store on a newer rollout than our server-side support (Shopify's
37
+ # `2026-08-25` endpoints, today) negotiates against the profile, so
38
+ # pinning the profile to the server's revision under-declared us.
33
39
  #
34
40
  # Rotation is the future-proofing this exists for: re-running with
35
41
  # `rotate: true` keeps every key already published (so a request
@@ -38,10 +44,49 @@ module Portage
38
44
  # drops a key — retiring one is a deliberate, separate edit once
39
45
  # nothing signs with it any more.
40
46
  class AgentProfile
41
- UCP_VERSION = Portage::Ucp::Manifest::UCP_VERSION
47
+ UCP_VERSION = "2026-08-25".freeze
48
+
49
+ SHOPPING_SERVICE = "dev.ucp.shopping".freeze
42
50
 
43
51
  Key = Struct.new(:kid, :jwk, :private_pem, keyword_init: true)
44
52
 
53
+ # The capability identifiers a real UCP server resolves an incoming
54
+ # agent's tool registry from. These are NOT
55
+ # `Portage::Ucp::Capabilities::{CATALOG,CART,...}.name`, which an
56
+ # earlier revision of this class reused, and that reuse is what broke
57
+ # every live tool call for a month (see
58
+ # docs/ucp-tool-gating-investigation.md):
59
+ #
60
+ # - Catalog is registered per *action*
61
+ # (`dev.ucp.shopping.catalog.search`, `.catalog.lookup`), not as one
62
+ # coarse `dev.ucp.shopping.catalog`. A profile declaring only the
63
+ # coarse name resolves to zero catalog tools, and the server then
64
+ # answers `search_catalog` with `-32602 Tool not found:
65
+ # search_catalog` — despite `tools/list` having advertised it
66
+ # seconds earlier, and with no hint that the profile is the reason.
67
+ # `Portage::Ucp::Capabilities::CATALOG` keeps the coarse name
68
+ # because that's the right shape for *our own* server's manifest,
69
+ # where one Capability object owns all three actions; the two
70
+ # registries simply don't line up, so this document spells its own
71
+ # ids out rather than deriving them.
72
+ # - Cart/Checkout/Order are registered at the root name, so those do
73
+ # match — spelled out here anyway, so the whole declared set reads
74
+ # from one place.
75
+ # - Versions are spec revisions (`2026-08-25`), not the `"1"` that
76
+ # `Capability#version` carries.
77
+ #
78
+ # Live-verified 2026-09-22 against `catalog.shopify.com/api/ucp/mcp`
79
+ # and two per-shop endpoints: the granular ids answer `search_catalog`
80
+ # with real products at the anonymous tier (no token, no allowlist),
81
+ # the coarse id answers `Tool not found` on the same connection.
82
+ CAPABILITY_IDS = %w[
83
+ dev.ucp.shopping.catalog.search
84
+ dev.ucp.shopping.catalog.lookup
85
+ dev.ucp.shopping.cart
86
+ dev.ucp.shopping.checkout
87
+ dev.ucp.shopping.order
88
+ ].freeze
89
+
45
90
  # @param out [String] path to write the public profile JSON document
46
91
  # @param key_out [String] path to write the new private key's PEM —
47
92
  # caller's responsibility to keep this out of version control
@@ -83,14 +128,29 @@ module Portage
83
128
  {
84
129
  "ucp" => {
85
130
  "version" => UCP_VERSION,
86
- "services" => {},
87
- "capabilities" => {},
131
+ "services" => service_hash,
132
+ "capabilities" => capability_hash,
88
133
  "payment_handlers" => {}
89
134
  },
90
135
  "signing_keys" => signing_keys
91
136
  }
92
137
  end
93
138
 
139
+ # Was `{}`. A server negotiating capabilities intersects its own
140
+ # service list with the profile's, so an empty `services` declares an
141
+ # agent that speaks no service at all — same class of under-declaration
142
+ # as the coarse capability ids above.
143
+ def service_hash
144
+ { SHOPPING_SERVICE => [{ "version" => UCP_VERSION,
145
+ "spec" => "https://ucp.dev/#{UCP_VERSION}/specification/overview",
146
+ "transport" => "mcp",
147
+ "schema" => "https://ucp.dev/#{UCP_VERSION}/services/shopping/mcp.openrpc.json" }] }
148
+ end
149
+
150
+ def capability_hash
151
+ CAPABILITY_IDS.to_h { |id| [id, [{ "version" => UCP_VERSION }]] }
152
+ end
153
+
94
154
  def write_profile(doc)
95
155
  FileUtils.mkdir_p(File.dirname(@out))
96
156
  File.write(@out, "#{JSON.pretty_generate(doc)}\n")
@@ -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
- def record_purchase(url:, query:, checkout:, message:, checkout_status: nil, products: [])
22
- append("purchases", { "url" => url, "query" => query, "checkout" => checkout,
23
- "checkout_status" => checkout_status, "message" => message,
24
- "products" => products, "at" => @now })
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
- def record_search(query:, offer_count:, message:)
28
- append("searches", { "query" => query, "offer_count" => offer_count, "message" => message, "at" => @now })
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