portage-cli 0.8.0 → 0.10.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 CHANGED
@@ -1,7 +1,7 @@
1
1
  ---
2
2
  SHA256:
3
- metadata.gz: 805f3c78c6560bcdb4110d1bb97dcaa0700a7586578cc639fb94595cfd5c09bc
4
- data.tar.gz: f2f4ca834b30fb319563ea10741385150559437bdfd87bc4657051d1f2dc0182
3
+ metadata.gz: c506344f3b17a2ea4199f84efaf70d658b0debec2fe03d167b847c58617e800b
4
+ data.tar.gz: 20c00c246e59fa9059611f7bb54380140295eada2c5deb457a11ec94309c1b04
5
5
  SHA512:
6
- metadata.gz: 3b10afae4ce3260952193fd8c32b7eb8b5a97b8a4292ca78e40848a41fd94f84e3f18490115186296253edd1d8fdf65a1cd728c1eca5e96efc8a21c18563ebcb
7
- data.tar.gz: 03b8c8ba8a3ee6e19116d00f6775c6825e3c70d23f7a4b1864acb44435988edf9ba400a52cdf5548ee9660e0588cd938f0c0eba6ed24afcea0010d2669abddb3
6
+ metadata.gz: 9a3eff64f57ab81774a2a52034232a4667227aaff13aa7279d26dd41cf52faeafeea691711755fb41fd2f3a0a67f77d144d248d48c2631779a49c10bec9f2882
7
+ data.tar.gz: ade0a2ba64ba928ec1272a8902858bad1f79897f5059d428528b8978e4045047ee1a0e6e2579dc2f8a468fa73b0c3491cc66c48f2bc1a18951600e05bd2a25e1
data/CHANGELOG.md CHANGED
@@ -6,6 +6,75 @@ pre-1.0, so APIs may still shift between minor versions.
6
6
 
7
7
  ## [Unreleased]
8
8
 
9
+ ## [0.10.0] - 2026-09-29
10
+
11
+ - **`portage check <url> [--json]`.** Reports whether Portage can buy from a store and
12
+ how: `verdict` (`automated`, `webmcp`, `handoff`, `unsupported`), `next_step`, and the
13
+ detail behind them (native UCP, platform, adapter install and missing env, hand-off-only,
14
+ WebMCP status). Wraps `Portage::Ucp::Check` and follows the precedence `buy` uses.
15
+ Plain GETs only, hand-off-only hosts are not contacted, and WebMCP is read only from an
16
+ already-open Portage profile tab. Exits `0` for `automated` and `webmcp`. The hand-off-only
17
+ test moved into a shared `HandoffHost` so `buy` and `check` can't disagree.
18
+ Follows a homepage `<link rel="ucp">` manifest pointer through `portage-ucp` 0.11.0,
19
+ which this release now requires (`~> 0.11`).
20
+
21
+ ## [0.9.0] - 2026-09-29
22
+
23
+ - **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
24
+ Loop steps 3 and 5 now have a ready-made interface for a person at a terminal
25
+ and for an agent relaying their answer.
26
+ - **Offer refs.** Each `find` offer carries an `offer_ref` (`of_` and 6 hex
27
+ digits), and the report a `search_id` (`se_` and 8 hex digits). Both are saved
28
+ with the search in history, so `history --json` searches now hold `search_id`
29
+ and `offers[]`. `compare` saves its offers with refs and a `search_id` too.
30
+ `portage buy --offer REF` takes the store, product and query from the saved
31
+ offer; an unknown ref is `offer_not_found`.
32
+ - **Quotes.** A `buy --dry-run` that priced a checkout saves a quote in
33
+ `~/.portage/quotes/` and reports `quote_id`. `buy --quote QUOTE_ID --yes`
34
+ buys exactly that quote, capped at the quoted total: if the real checkout costs
35
+ more (or is in another currency) it refuses with `quote_changed`, carrying
36
+ `quoted_total` and `current_total`, without charging or handing off. Quotes don't
37
+ expire and are used once (`purchased` or any hand-off). `quote_not_found` and
38
+ `quote_used` cover the rest.
39
+ - **`portage pick`.** Loop step 3. `needs_pick` returns `choices[]` (`ref`, `label`,
40
+ `url`, plus the offer's fields) and a last "Compare an offer across stores"
41
+ choice; `--choose REF` relays an answer (`picked`, `by: "agent_relayed"`),
42
+ `--compare REF` runs compare and shows the pick again over its results,
43
+ `--search LAST|SEARCH_ID` picks the search. At a terminal it asks on `/dev/tty`
44
+ (`by: "person"`).
45
+ - **`portage approve QUOTE_ID`.** Loop step 5. `needs_approval` returns a
46
+ `summary` (title, store, qty, total, `total_display`, `url`); `--relayed-yes`
47
+ records `approved_by: "agent_relayed"`; a yes typed at a terminal records
48
+ `"person"`. The quote also keeps `approved`, `approved_by` and `approved_at`.
49
+ - **`--via auto|tty|agent`.** `tty` asks on `/dev/tty` (so it works with stdout
50
+ piped) and returns `no_terminal` when there's none; `agent` asks nobody;
51
+ `auto` is `tty` with a terminal and no `--json`, else `agent`.
52
+ - **`--view REF` / `approve --view` and `v N` / `v` at a prompt** open the product
53
+ page and never count as an answer. Only an `http(s)` URL on the offer's own
54
+ store host is opened; anything else is `view_refused`. `pick`'s compare
55
+ choice uses the proxy settings from env and config (there are no `--proxy`
56
+ flags on `pick`).
57
+ - **`policy set --require-approval person|any|off`** (default `any`, stored as
58
+ `require_approval` in `~/.portage/policy.json`, always shown by `policy show`).
59
+ Lowering it needs a yes typed at a terminal. It raises the bar against an agent
60
+ but isn't a hard guarantee: a process with a shell can edit the policy or quote
61
+ files. Docs: `docs/api/cli-json.md`, `docs/agentic-flow.md`,
62
+ `docs/cli-usage-tutorial.md`.
63
+ - **Upgrade note.** Under the default `any`, a `buy --yes` without an approved
64
+ `--quote` no longer buys: it dry-runs and returns `needs_approval` (exit `0`).
65
+ Restore the old behaviour with `portage policy set --require-approval off`
66
+ from a terminal. `buy --query`'s numbered pick now asks on `/dev/tty` too.
67
+
68
+ - Documentation only, no code change. The README said a
69
+ `webmcp_mapping_unconfirmed` report returns the proposed mapping "for the
70
+ caller to pass back". No flag takes a mapping back, and `Buy` has no
71
+ `tool_names:` keyword. The README now says what each caller can do. From
72
+ the CLI, re-run the command in a real terminal without `--json`
73
+ (`--dry-run` is enough) and answer the prompt; the approved mapping is
74
+ saved to `~/.portage/webmcp_mappings.json`. From Ruby, inject
75
+ `webmcp_mapping_confirm:` or `webmcp_mappings:`, or pass `tool_names:` to
76
+ `WebMcp.connect` yourself.
77
+
9
78
  ## [0.8.0] - 2026-09-29
10
79
 
11
80
  - **Requires `portage-ucp-webmcp` 0.2.0 or newer for WebMCP paths.**
data/README.md CHANGED
@@ -127,10 +127,16 @@ portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id
127
127
  [--handoff-target default|print|profile|agent:NAME]
128
128
  [--decision-backend jev|laya] [--min-confidence N] [--json]
129
129
  [--wait [--wait-timeout DURATION|off]]
130
+ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
131
+ portage buy --quote QUOTE_ID --yes [--json] ...
130
132
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
131
133
  portage find --query "..." [--max-price N] [--limit N] [--json]
132
134
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
133
135
  [--max-price N] [--json]
136
+ portage check <url> [--json]
137
+ portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
138
+ [--choose REF | --compare REF | --view REF]
139
+ portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
134
140
  portage history [list] [--purchases|--searches] [--limit N] [--json]
135
141
  portage history clear [--purchases|--searches]
136
142
  portage payment list [--json]
@@ -145,6 +151,7 @@ portage policy set [--per-transaction-cap N --currency CUR]
145
151
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
146
152
  [--velocity-count N --velocity-window-seconds N]
147
153
  [--allow HOST ...] [--clear-allowlist]
154
+ [--require-approval person|any|off] (lowering asks at a terminal)
148
155
  portage orders reconcile [--checkout ID] [--json]
149
156
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
150
157
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
@@ -212,6 +219,18 @@ used (an unreadable value, an out-of-range threshold) stops the buy before
212
219
  anything runs: on stderr normally, or as a JSON report with `outcome:
213
220
  "invalid_option"` under `--json`.
214
221
 
222
+ ### Check
223
+
224
+ `portage check <url> [--json]` answers "can Portage buy from this store, and how?".
225
+ It checks for a native `/.well-known/ucp` manifest, detects the platform, notes
226
+ whether its adapter gem is installed and which env vars are missing, and looks for
227
+ WebMCP tools, using the same hand-off-only rules as `buy`. `verdict` is
228
+ `automated`, `webmcp`, `handoff` or `unsupported`, with a plain-English
229
+ `next_step`. Exits `0` for `automated` and `webmcp`. It sends plain GETs only, and
230
+ never contacts a hand-off-only host. WebMCP is read only from a tab your Portage
231
+ browser profile already has open on the store; it never launches a browser.
232
+ Takes the `--proxy*` flags.
233
+
215
234
  ### Compare
216
235
 
217
236
  `portage compare <url> --product-id ID` finds other stores selling the same
@@ -361,6 +380,21 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
361
380
  limits bound to one enrolled card) are set via `portage payment enroll
362
381
  --scope-*` above, not here.
363
382
 
383
+ `portage policy set --require-approval person|any|off` (default `any`) sets what a
384
+ real `buy --yes` needs: `off` is `--yes` alone; `any` needs `--quote QUOTE_ID` for a
385
+ quote approved with `portage approve` (by the person, or relayed by an agent with
386
+ `--relayed-yes`); `person` needs the person's own yes at a terminal. Otherwise the run
387
+ is a dry run that returns `needs_approval`. Lowering the level asks for a yes at a
388
+ terminal. Stored as `require_approval` in `~/.portage/policy.json`. It raises the bar
389
+ against an agent but isn't a hard guarantee: a process with a shell can edit that file
390
+ or the quote files in `~/.portage/quotes/`. The whole flow (`find`, `pick`, `buy
391
+ --offer --dry-run`, `approve`, `buy --quote --yes`) is in the
392
+ [CLI JSON reference](../docs/api/cli-json.md) and the
393
+ [tutorial](../docs/cli-usage-tutorial.md#picking-and-approving-at-the-terminal). Upgrade
394
+ note: under the default `any`, `buy --yes` with no approved `--quote` no longer buys;
395
+ restore the old behaviour with `portage policy set --require-approval off` from a
396
+ terminal.
397
+
364
398
  ### Tiers: how a purchase actually finishes
365
399
 
366
400
  Most stores don't let a third-party agent complete payment. `portage buy`
@@ -737,9 +771,24 @@ hand-off. `token` isn't implemented yet; it reports
737
771
  Against a page whose tools aren't a known platform preset, `Buy` falls back
738
772
  to a schema-matched, shopper-confirmed mapping instead of giving up (see
739
773
  `portage-ucp-webmcp`'s README, "Stores that don't run Portage"). A mutating
740
- match prompts on a real TTY with `--json` off; under `--json` or with no
741
- TTY it stops instead (outcome `webmcp_mapping_unconfirmed`) and returns the
742
- proposed mapping for the caller to pass back.
774
+ match prompts on a real TTY with `--json` off. Under `--json`, or with no
775
+ TTY, it stops instead: outcome `webmcp_mapping_unconfirmed`, with the
776
+ proposal in `tool_names_proposal`.
777
+
778
+ No flag passes a mapping back. From the CLI, re-run the same command in
779
+ your own terminal without `--json` and answer the prompt. `--dry-run` is
780
+ enough, because the mapping is confirmed before the dry-run check. The
781
+ approved mapping is saved to `~/.portage/webmcp_mappings.json`, and later
782
+ runs reuse it with no prompt.
783
+
784
+ `Buy` has no `tool_names:` keyword either. A library caller has two hooks.
785
+ `webmcp_mapping_confirm:` takes any object whose `call(proposal, tools)`
786
+ returns a `tool_names:` hash, or nil to stop with
787
+ `webmcp_mapping_unconfirmed`. `Buy` saves whatever hash it returns to
788
+ `webmcp_mappings:`, the store approved mappings are read from (default: a
789
+ `Portage::Cli::WebmcpMappings` on `~/.portage/webmcp_mappings.json`).
790
+ Outside `Buy`, pass `tool_names:` to `Portage::Ucp::WebMcp.connect`
791
+ yourself.
743
792
 
744
793
  `dry_run: true` against a page whose preset hands off through its own
745
794
  checkout tool (Shopify's `proceed_to_checkout`) stops after the read-only
@@ -0,0 +1,56 @@
1
+ require "portage/ucp"
2
+ require_relative "human_prompt"
3
+
4
+ module Portage
5
+ module Cli
6
+ # `portage policy set --require-approval person|any|off`
7
+ # (docs/plans/human-pick-and-approve.md Phase 2, Design § 5): how much
8
+ # approval a real `portage buy --yes` needs before it may charge or hand
9
+ # off.
10
+ #
11
+ # - `off`: `--yes` alone buys (the behaviour before this setting).
12
+ # - `any` (the default): `--yes` needs `--quote` with an approved quote,
13
+ # approved by the person at a tty or relayed by an agent.
14
+ # - `person`: only a quote the person approved at the tty counts — a
15
+ # model with a shell can't type on `/dev/tty`.
16
+ #
17
+ # Stored as `require_approval` in ~/.portage/policy.json (Portage::Ucp::
18
+ # Policy), next to the caps and allowlist it sits beside in `portage
19
+ # policy`. Policy#set takes any key and its file format is documented as
20
+ # private, and PolicyGuard reads only the keys it knows, so this needs
21
+ # no change to the portage-ucp gem. Deliberately no env var or
22
+ # config.json override: either would let an agent lower it without the
23
+ # tty confirmation `policy set` asks for. The gate itself is CLI-only
24
+ # (Cli.approval_gate); Buy's library callers aren't governed by it.
25
+ module ApprovalPolicy
26
+ KEY = "require_approval".freeze
27
+ LEVELS = %w[off any person].freeze
28
+ DEFAULT = "any".freeze
29
+
30
+ module_function
31
+
32
+ # An unrecognised stored value (a hand-edited file) fails closed, to
33
+ # the strictest level.
34
+ # @return [String] one of LEVELS.
35
+ def level(policy = Portage::Ucp::Policy.load)
36
+ stored = policy.to_h[KEY]
37
+ return DEFAULT if stored.nil?
38
+
39
+ LEVELS.include?(stored) ? stored : "person"
40
+ end
41
+
42
+ def configured?(policy) = policy.to_h.key?(KEY)
43
+
44
+ def lowering?(from, to) = LEVELS.index(to) < LEVELS.index(from)
45
+
46
+ # @param quote [Hash, nil] a saved quote (Quotes).
47
+ def satisfied?(quote, level)
48
+ case level
49
+ when "off" then true
50
+ when "any" then [HumanPrompt::BY_PERSON, HumanPrompt::BY_AGENT].include?(quote&.dig("approved_by"))
51
+ else quote&.dig("approved_by") == HumanPrompt::BY_PERSON
52
+ end
53
+ end
54
+ end
55
+ end
56
+ end
@@ -0,0 +1,135 @@
1
+ require_relative "history"
2
+ require_relative "quotes"
3
+ require_relative "human_prompt"
4
+ require_relative "product_page"
5
+ require_relative "money"
6
+ require_relative "approval_policy"
7
+
8
+ module Portage
9
+ module Cli
10
+ # `portage approve QUOTE_ID` — loop step 5, the person says yes to the
11
+ # exact dry-run total (docs/plans/human-pick-and-approve.md Phase 2,
12
+ # Design § 5). Shows title, store, qty and total and asks yes/no.
13
+ #
14
+ # A yes typed on the `tty` surface marks the quote `approved_by:
15
+ # "person"`; `--relayed-yes` marks it `"agent_relayed"`. The `agent`
16
+ # surface asks nobody and returns `needs_approval` with the summary.
17
+ # `--view` only opens the product page. Whether an approval is enough
18
+ # for `buy --quote QUOTE_ID --yes` is ApprovalPolicy's call, at buy time
19
+ # — except under `person`, where a relayed yes could never be enough, so
20
+ # it isn't recorded and the agent is told to hand the question to the
21
+ # person's own terminal instead.
22
+ #
23
+ # Returns a report hash; Cli.run_approve prints it.
24
+ class Approve
25
+ def initialize(quote_id:, prompt:, relayed_yes: false, view: false, quotes: Quotes.new, history: History.new,
26
+ level: ApprovalPolicy.level)
27
+ @quote_id = quote_id
28
+ @prompt = prompt
29
+ @relayed_yes = relayed_yes
30
+ @view = view
31
+ @quotes = quotes
32
+ @history = history
33
+ @level = level
34
+ end
35
+
36
+ def call
37
+ quote = @quotes.find(@quote_id)
38
+ return unusable(quote) if quote.nil? || quote["used_at"]
39
+
40
+ summary = self.class.summary(quote, history: @history)
41
+ return view(summary) if @view
42
+ return relay(summary) if @relayed_yes
43
+ return self.class.needs_approval(summary, level: @level) unless @prompt.tty?
44
+
45
+ ask(summary)
46
+ rescue HumanPrompt::NoTerminal => e
47
+ { outcome: "no_terminal", quote_id: @quote_id, message: e.message }
48
+ end
49
+
50
+ # What the person is asked to approve. Title and url were saved on
51
+ # the quote at dry-run time; a quote saved without them falls back to
52
+ # its offer's (via `offer_ref`, History#offer).
53
+ def self.summary(quote, history: History.new)
54
+ offer = fallback_offer(quote, history)
55
+ { quote_id: quote["quote_id"], title: quote["title"] || offer&.dig("title"), store: quote["store"],
56
+ product_id: quote["product_id"], qty: quote["qty"], total: quote["total"], currency: quote["currency"],
57
+ total_display: quote["total"] ? Money.format_amount(quote["total"], quote["currency"]) : "unknown",
58
+ url: quote["url"] || offer&.dig("url"), approved_by: quote["approved_by"] }
59
+ end
60
+
61
+ def self.fallback_offer(quote, history)
62
+ return nil unless quote["offer_ref"] && (quote["title"].nil? || quote["url"].nil?)
63
+
64
+ history.offer(quote["offer_ref"])
65
+ end
66
+ private_class_method :fallback_offer
67
+
68
+ # Also what a refused `buy --yes` returns (Cli.approval_gate).
69
+ def self.needs_approval(summary, message: nil, level: ApprovalPolicy::DEFAULT)
70
+ { outcome: "needs_approval", quote_id: summary[:quote_id], summary: summary,
71
+ message: message || relay_message(summary, level) }
72
+ end
73
+
74
+ def self.relay_message(summary, level)
75
+ id = summary[:quote_id]
76
+ ask = if level == "person"
77
+ "Ask the person to run `portage approve #{id}` in their own terminal (require_approval: person " \
78
+ "doesn't accept a relayed yes)"
79
+ else
80
+ "Ask the person to approve #{describe(summary)} (show the url as a link), then relay a yes: " \
81
+ "`portage approve #{id} --relayed-yes`"
82
+ end
83
+ "#{ask}, then `portage buy --quote #{id} --yes`."
84
+ end
85
+ private_class_method :relay_message
86
+
87
+ def self.describe(summary)
88
+ "#{summary[:qty]} × #{summary[:title] || summary[:product_id]} from #{summary[:store]} " \
89
+ "for #{summary[:total_display]}"
90
+ end
91
+
92
+ private
93
+
94
+ def ask(summary)
95
+ @prompt.say("#{summary[:title] || summary[:product_id]} — #{summary[:store]}")
96
+ @prompt.say(" qty #{summary[:qty]}, total #{summary[:total_display]}#{" — #{summary[:url]}" if summary[:url]}")
97
+ yes = @prompt.confirm("Buy #{self.class.describe(summary)}?", view: -> { page(summary)[:message] })
98
+ return approve(summary, HumanPrompt::BY_PERSON) if yes
99
+
100
+ { outcome: "cancelled", quote_id: summary[:quote_id], message: "Not approved — nothing will be bought." }
101
+ end
102
+
103
+ def relay(summary)
104
+ return self.class.needs_approval(summary, level: @level) if @level == "person"
105
+
106
+ approve(summary, HumanPrompt::BY_AGENT)
107
+ end
108
+
109
+ def approve(summary, by)
110
+ quote = @quotes.approve(summary[:quote_id], by: by)
111
+ return { outcome: "error", quote_id: @quote_id, message: "Couldn't save the approval." } unless quote
112
+
113
+ { outcome: "approved", quote_id: summary[:quote_id], approved_by: quote["approved_by"],
114
+ summary: summary.merge(approved_by: quote["approved_by"]),
115
+ message: "Approved #{self.class.describe(summary)} — next: " \
116
+ "`portage buy --quote #{summary[:quote_id]} --yes`." }
117
+ end
118
+
119
+ def view(summary) = page(summary).merge(quote_id: summary[:quote_id])
120
+
121
+ def page(summary) = ProductPage.new(url: summary[:url], store: summary[:store]).open
122
+
123
+ # Same outcomes `buy --quote` reports for the same two cases.
124
+ def unusable(quote)
125
+ if quote
126
+ { outcome: "quote_used", quote_id: @quote_id,
127
+ message: "Quote #{@quote_id} has already been used — dry-run again for a new one." }
128
+ else
129
+ { outcome: "quote_not_found", quote_id: @quote_id,
130
+ message: "No saved quote #{@quote_id} — run `portage buy ... --dry-run --json` for a new one." }
131
+ end
132
+ end
133
+ end
134
+ end
135
+ end
@@ -0,0 +1,38 @@
1
+ module Portage
2
+ module Cli
3
+ # The one shell-out that opens a URL in the shopper's browser, shared by
4
+ # CheckoutHandoff (a dead-end checkout's auto-open) and ProductPage
5
+ # (docs/plans/human-pick-and-approve.md Phase 2's "view the product
6
+ # page"). Mixed in rather than called as a module function so `system`
7
+ # stays the including object's own — specs stub it per instance.
8
+ #
9
+ # No new gem for the actual open — every other shell-out in this repo
10
+ # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
11
+ # `system` rather than pulling in launchy for something the OS already
12
+ # provides. `system(cmd, url)` (array form, never an interpolated
13
+ # string) so a merchant-controlled URL can't inject into a shell.
14
+ # Whether a URL is safe to open at all is the caller's check.
15
+ module BrowserOpener
16
+ private
17
+
18
+ # @return [Boolean] whether the browser was actually opened.
19
+ def open_browser(url)
20
+ command = platform_command
21
+ return false unless command
22
+
23
+ !!system(command, url)
24
+ rescue StandardError => e
25
+ warn "portage: couldn't open #{url} (#{e.message})"
26
+ false
27
+ end
28
+
29
+ def platform_command
30
+ case RbConfig::CONFIG["host_os"]
31
+ when /darwin/i then "open"
32
+ when /linux|bsd/i then "xdg-open"
33
+ when /mswin|mingw|cygwin/i then "start"
34
+ end
35
+ end
36
+ end
37
+ end
38
+ end
@@ -10,8 +10,10 @@ require_relative "setting"
10
10
  require_relative "decisions"
11
11
  require_relative "confidence_check"
12
12
  require_relative "checkout_handoff"
13
+ require_relative "money"
13
14
  require_relative "notifier"
14
15
  require_relative "handoff_only"
16
+ require_relative "handoff_host"
15
17
  require_relative "offer_sources"
16
18
  require_relative "handoff_target"
17
19
  require_relative "handoff_agents"
@@ -115,12 +117,18 @@ module Portage
115
117
  # default) builds one from the same interactive? posture as Phase
116
118
  # 2's webmcp_mapping_confirm; injectable so a spec can simulate an
117
119
  # interactive "y" without a real terminal.
120
+ # @param quote_total [Integer, nil] minor units — set by `buy --quote`:
121
+ # the total the person was quoted. Every path that would charge or
122
+ # hand off the real checkout first checks its total against this
123
+ # (with `quote_currency:`) and reports `quote_changed` instead if it
124
+ # is higher, in another currency, or missing. See #finish_checkout.
118
125
  # rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
119
126
  # injectable collaborators, each assigned to its own ivar
120
127
  def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
121
128
  auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
122
129
  transaction_log: nil, max_price: nil, webmcp_bridge: nil, webmcp_mappings: nil,
123
- webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false)
130
+ webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
131
+ quote_total: nil, quote_currency: nil)
124
132
  # rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
125
133
  raw = url.to_s.strip
126
134
  @uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
@@ -141,6 +149,8 @@ module Portage
141
149
  @autofill = autofill
142
150
  @webmcp_autofill_confirm = webmcp_autofill_confirm
143
151
  @json = json
152
+ @quote_total = quote_total
153
+ @quote_currency = quote_currency
144
154
  @webmcp_bridge = webmcp_bridge
145
155
  @decisions = {}
146
156
  end
@@ -180,32 +190,10 @@ module Portage
180
190
  # "handoff_only_hosts" is the user's own list (see HandoffOnly).
181
191
  def handoff_only?
182
192
  @handoff_only ||= HandoffOnly.new
183
- return true if @handoff_only.host?(@uri.host)
184
- return true if OfferSources.retail_handoff_host?(@uri.host)
185
-
186
- etsy_buyer_host?
187
- end
188
-
189
- # Phase 7 (docs/plans/buy-skill-and-local-browser.md):
190
- # portage-ucp-etsy is a *seller*-side adapter — a shop owner with
191
- # their own ETSY_* credentials set still reaches #adapter_flow
192
- # unchanged below, the same "only your own store" rule every other
193
- # adapter already gets (see the class comment at the top of this
194
- # file). An ordinary buyer with no Etsy credentials of their own
195
- # gets routed to hand-off here instead of a homepage fetch +
196
- # platform detection that would just be scraping a stranger's
197
- # listing — the buyer-side offer OfferSources::EtsyListings hands
198
- # `find` has nothing this process can check out anyway.
199
- def etsy_buyer_host?
200
- HandoffOnly.matches_any?(@uri.host, %w[etsy.com]) && !etsy_adapter_configured?
193
+ HandoffHost.restricted?(@uri.host, handoff_only: @handoff_only)
201
194
  end
202
195
 
203
- def etsy_adapter_configured?
204
- platform = Portage::Ucp::Resolver::PLATFORMS.find { |p| p.name == "Etsy" }
205
- return false unless platform
206
-
207
- Portage::Ucp::Resolver.missing_env(platform, Portage::Ucp::Resolver.env_for(platform)).empty?
208
- end
196
+ def etsy_buyer_host? = HandoffHost.etsy_buyer_host?(@uri.host)
209
197
 
210
198
  # No checkout was ever built — there's nothing to browse or complete,
211
199
  # just a link and why. `checkout_url` is built, never fetched: the
@@ -970,6 +958,8 @@ module Portage
970
958
  end
971
959
 
972
960
  def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
961
+ return quote_changed_report(source, products, checkout, warnings) if quote_exceeded?(checkout)
962
+
973
963
  escalation = decide_escalation(checkout, warnings)
974
964
  return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
975
965
  return dry_run_report(source, products, checkout, warnings) if @dry_run
@@ -979,6 +969,33 @@ module Portage
979
969
  complete(session, source, products, checkout, warnings)
980
970
  end
981
971
 
972
+ # First in #finish_checkout, ahead of the escalation gates: a
973
+ # `quote_changed` refusal never hands off, since that would spend the
974
+ # quote and open a checkout the person never approved. #complete is
975
+ # the only place this file charges, and it is reached only through
976
+ # #finish_checkout.
977
+ def quote_exceeded?(checkout)
978
+ return false unless @quote_total
979
+
980
+ total = checkout_total(checkout)
981
+ total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
982
+ end
983
+
984
+ def quote_changed_report(source, products, checkout, warnings)
985
+ total = checkout_total(checkout)
986
+ checkout_report(source, products, checkout, outcome: "quote_changed", warnings: warnings,
987
+ quoted_total: @quote_total, quoted_currency: @quote_currency,
988
+ current_total: total, current_currency: checkout["currency"],
989
+ message: quote_changed_message(total, checkout["currency"]))
990
+ end
991
+
992
+ def quote_changed_message(total, currency)
993
+ "The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
994
+ "now #{quoted_amount(total, currency)}) — nothing was bought."
995
+ end
996
+
997
+ def quoted_amount(amount, currency) = amount ? Money.format_amount(amount, currency) : "unknown"
998
+
982
999
  # Hand off vs. keep going is Decisions.escalation's call
983
1000
  # (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
984
1001
  # literal `requires_escalation` status always escalates. A mismatch