portage-cli 0.8.0 → 0.9.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 +57 -0
- data/README.md +39 -3
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -0
- data/lib/portage/cli/browser_opener.rb +38 -0
- data/lib/portage/cli/buy.rb +39 -1
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- data/lib/portage/cli/find.rb +8 -1
- data/lib/portage/cli/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -0
- data/lib/portage/cli/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli.rb +408 -41
- metadata +10 -1
checksums.yaml
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
---
|
|
2
2
|
SHA256:
|
|
3
|
-
metadata.gz:
|
|
4
|
-
data.tar.gz:
|
|
3
|
+
metadata.gz: 3d34c9ba25977baefa9f1f802e38ae6b2175e455d246f7a0dd3a2833136c46e8
|
|
4
|
+
data.tar.gz: 2c1d701bafd9c914115bdb02ab661906eaddba139e0627a56bfa0cf094e71d06
|
|
5
5
|
SHA512:
|
|
6
|
-
metadata.gz:
|
|
7
|
-
data.tar.gz:
|
|
6
|
+
metadata.gz: ec3b6bf08d832abbe197591050b229f38a3650603feb6e94b47a6e84fbb9d3b9ddf531c51e84d71c8b708131b655e6b501e6a55662908eb6edb60981da4f14a2
|
|
7
|
+
data.tar.gz: 137b718fd1283383aa70879a6f2290a35dc1bc3f3a898ceff6965019b110329caf943dcf3ef39c04c29d1661beee3da6edd3a056877b7a6c3a932debce90c99f
|
data/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,63 @@ pre-1.0, so APIs may still shift between minor versions.
|
|
|
6
6
|
|
|
7
7
|
## [Unreleased]
|
|
8
8
|
|
|
9
|
+
## [0.9.0] - 2026-09-29
|
|
10
|
+
|
|
11
|
+
- **Human pick and approve** (`docs/plans/human-pick-and-approve.md`, Phases 1-3).
|
|
12
|
+
Loop steps 3 and 5 now have a ready-made interface for a person at a terminal
|
|
13
|
+
and for an agent relaying their answer.
|
|
14
|
+
- **Offer refs.** Each `find` offer carries an `offer_ref` (`of_` and 6 hex
|
|
15
|
+
digits), and the report a `search_id` (`se_` and 8 hex digits). Both are saved
|
|
16
|
+
with the search in history, so `history --json` searches now hold `search_id`
|
|
17
|
+
and `offers[]`. `compare` saves its offers with refs and a `search_id` too.
|
|
18
|
+
`portage buy --offer REF` takes the store, product and query from the saved
|
|
19
|
+
offer; an unknown ref is `offer_not_found`.
|
|
20
|
+
- **Quotes.** A `buy --dry-run` that priced a checkout saves a quote in
|
|
21
|
+
`~/.portage/quotes/` and reports `quote_id`. `buy --quote QUOTE_ID --yes`
|
|
22
|
+
buys exactly that quote, capped at the quoted total: if the real checkout costs
|
|
23
|
+
more (or is in another currency) it refuses with `quote_changed`, carrying
|
|
24
|
+
`quoted_total` and `current_total`, without charging or handing off. Quotes don't
|
|
25
|
+
expire and are used once (`purchased` or any hand-off). `quote_not_found` and
|
|
26
|
+
`quote_used` cover the rest.
|
|
27
|
+
- **`portage pick`.** Loop step 3. `needs_pick` returns `choices[]` (`ref`, `label`,
|
|
28
|
+
`url`, plus the offer's fields) and a last "Compare an offer across stores"
|
|
29
|
+
choice; `--choose REF` relays an answer (`picked`, `by: "agent_relayed"`),
|
|
30
|
+
`--compare REF` runs compare and shows the pick again over its results,
|
|
31
|
+
`--search LAST|SEARCH_ID` picks the search. At a terminal it asks on `/dev/tty`
|
|
32
|
+
(`by: "person"`).
|
|
33
|
+
- **`portage approve QUOTE_ID`.** Loop step 5. `needs_approval` returns a
|
|
34
|
+
`summary` (title, store, qty, total, `total_display`, `url`); `--relayed-yes`
|
|
35
|
+
records `approved_by: "agent_relayed"`; a yes typed at a terminal records
|
|
36
|
+
`"person"`. The quote also keeps `approved`, `approved_by` and `approved_at`.
|
|
37
|
+
- **`--via auto|tty|agent`.** `tty` asks on `/dev/tty` (so it works with stdout
|
|
38
|
+
piped) and returns `no_terminal` when there's none; `agent` asks nobody;
|
|
39
|
+
`auto` is `tty` with a terminal and no `--json`, else `agent`.
|
|
40
|
+
- **`--view REF` / `approve --view` and `v N` / `v` at a prompt** open the product
|
|
41
|
+
page and never count as an answer. Only an `http(s)` URL on the offer's own
|
|
42
|
+
store host is opened; anything else is `view_refused`. `pick`'s compare
|
|
43
|
+
choice uses the proxy settings from env and config (there are no `--proxy`
|
|
44
|
+
flags on `pick`).
|
|
45
|
+
- **`policy set --require-approval person|any|off`** (default `any`, stored as
|
|
46
|
+
`require_approval` in `~/.portage/policy.json`, always shown by `policy show`).
|
|
47
|
+
Lowering it needs a yes typed at a terminal. It raises the bar against an agent
|
|
48
|
+
but isn't a hard guarantee: a process with a shell can edit the policy or quote
|
|
49
|
+
files. Docs: `docs/api/cli-json.md`, `docs/agentic-flow.md`,
|
|
50
|
+
`docs/cli-usage-tutorial.md`.
|
|
51
|
+
- **Upgrade note.** Under the default `any`, a `buy --yes` without an approved
|
|
52
|
+
`--quote` no longer buys: it dry-runs and returns `needs_approval` (exit `0`).
|
|
53
|
+
Restore the old behaviour with `portage policy set --require-approval off`
|
|
54
|
+
from a terminal. `buy --query`'s numbered pick now asks on `/dev/tty` too.
|
|
55
|
+
|
|
56
|
+
- Documentation only, no code change. The README said a
|
|
57
|
+
`webmcp_mapping_unconfirmed` report returns the proposed mapping "for the
|
|
58
|
+
caller to pass back". No flag takes a mapping back, and `Buy` has no
|
|
59
|
+
`tool_names:` keyword. The README now says what each caller can do. From
|
|
60
|
+
the CLI, re-run the command in a real terminal without `--json`
|
|
61
|
+
(`--dry-run` is enough) and answer the prompt; the approved mapping is
|
|
62
|
+
saved to `~/.portage/webmcp_mappings.json`. From Ruby, inject
|
|
63
|
+
`webmcp_mapping_confirm:` or `webmcp_mappings:`, or pass `tool_names:` to
|
|
64
|
+
`WebMcp.connect` yourself.
|
|
65
|
+
|
|
9
66
|
## [0.8.0] - 2026-09-29
|
|
10
67
|
|
|
11
68
|
- **Requires `portage-ucp-webmcp` 0.2.0 or newer for WebMCP paths.**
|
data/README.md
CHANGED
|
@@ -127,10 +127,15 @@ 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 pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
|
|
137
|
+
[--choose REF | --compare REF | --view REF]
|
|
138
|
+
portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
|
|
134
139
|
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
135
140
|
portage history clear [--purchases|--searches]
|
|
136
141
|
portage payment list [--json]
|
|
@@ -145,6 +150,7 @@ portage policy set [--per-transaction-cap N --currency CUR]
|
|
|
145
150
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
146
151
|
[--velocity-count N --velocity-window-seconds N]
|
|
147
152
|
[--allow HOST ...] [--clear-allowlist]
|
|
153
|
+
[--require-approval person|any|off] (lowering asks at a terminal)
|
|
148
154
|
portage orders reconcile [--checkout ID] [--json]
|
|
149
155
|
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
150
156
|
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
@@ -361,6 +367,21 @@ opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
|
|
|
361
367
|
limits bound to one enrolled card) are set via `portage payment enroll
|
|
362
368
|
--scope-*` above, not here.
|
|
363
369
|
|
|
370
|
+
`portage policy set --require-approval person|any|off` (default `any`) sets what a
|
|
371
|
+
real `buy --yes` needs: `off` is `--yes` alone; `any` needs `--quote QUOTE_ID` for a
|
|
372
|
+
quote approved with `portage approve` (by the person, or relayed by an agent with
|
|
373
|
+
`--relayed-yes`); `person` needs the person's own yes at a terminal. Otherwise the run
|
|
374
|
+
is a dry run that returns `needs_approval`. Lowering the level asks for a yes at a
|
|
375
|
+
terminal. Stored as `require_approval` in `~/.portage/policy.json`. It raises the bar
|
|
376
|
+
against an agent but isn't a hard guarantee: a process with a shell can edit that file
|
|
377
|
+
or the quote files in `~/.portage/quotes/`. The whole flow (`find`, `pick`, `buy
|
|
378
|
+
--offer --dry-run`, `approve`, `buy --quote --yes`) is in the
|
|
379
|
+
[CLI JSON reference](../docs/api/cli-json.md) and the
|
|
380
|
+
[tutorial](../docs/cli-usage-tutorial.md#picking-and-approving-at-the-terminal). Upgrade
|
|
381
|
+
note: under the default `any`, `buy --yes` with no approved `--quote` no longer buys;
|
|
382
|
+
restore the old behaviour with `portage policy set --require-approval off` from a
|
|
383
|
+
terminal.
|
|
384
|
+
|
|
364
385
|
### Tiers: how a purchase actually finishes
|
|
365
386
|
|
|
366
387
|
Most stores don't let a third-party agent complete payment. `portage buy`
|
|
@@ -737,9 +758,24 @@ hand-off. `token` isn't implemented yet; it reports
|
|
|
737
758
|
Against a page whose tools aren't a known platform preset, `Buy` falls back
|
|
738
759
|
to a schema-matched, shopper-confirmed mapping instead of giving up (see
|
|
739
760
|
`portage-ucp-webmcp`'s README, "Stores that don't run Portage"). A mutating
|
|
740
|
-
match prompts on a real TTY with `--json` off
|
|
741
|
-
TTY it stops instead
|
|
742
|
-
|
|
761
|
+
match prompts on a real TTY with `--json` off. Under `--json`, or with no
|
|
762
|
+
TTY, it stops instead: outcome `webmcp_mapping_unconfirmed`, with the
|
|
763
|
+
proposal in `tool_names_proposal`.
|
|
764
|
+
|
|
765
|
+
No flag passes a mapping back. From the CLI, re-run the same command in
|
|
766
|
+
your own terminal without `--json` and answer the prompt. `--dry-run` is
|
|
767
|
+
enough, because the mapping is confirmed before the dry-run check. The
|
|
768
|
+
approved mapping is saved to `~/.portage/webmcp_mappings.json`, and later
|
|
769
|
+
runs reuse it with no prompt.
|
|
770
|
+
|
|
771
|
+
`Buy` has no `tool_names:` keyword either. A library caller has two hooks.
|
|
772
|
+
`webmcp_mapping_confirm:` takes any object whose `call(proposal, tools)`
|
|
773
|
+
returns a `tool_names:` hash, or nil to stop with
|
|
774
|
+
`webmcp_mapping_unconfirmed`. `Buy` saves whatever hash it returns to
|
|
775
|
+
`webmcp_mappings:`, the store approved mappings are read from (default: a
|
|
776
|
+
`Portage::Cli::WebmcpMappings` on `~/.portage/webmcp_mappings.json`).
|
|
777
|
+
Outside `Buy`, pass `tool_names:` to `Portage::Ucp::WebMcp.connect`
|
|
778
|
+
yourself.
|
|
743
779
|
|
|
744
780
|
`dry_run: true` against a page whose preset hands off through its own
|
|
745
781
|
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
|
data/lib/portage/cli/buy.rb
CHANGED
|
@@ -10,6 +10,7 @@ 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"
|
|
15
16
|
require_relative "offer_sources"
|
|
@@ -115,12 +116,18 @@ module Portage
|
|
|
115
116
|
# default) builds one from the same interactive? posture as Phase
|
|
116
117
|
# 2's webmcp_mapping_confirm; injectable so a spec can simulate an
|
|
117
118
|
# interactive "y" without a real terminal.
|
|
119
|
+
# @param quote_total [Integer, nil] minor units — set by `buy --quote`:
|
|
120
|
+
# the total the person was quoted. Every path that would charge or
|
|
121
|
+
# hand off the real checkout first checks its total against this
|
|
122
|
+
# (with `quote_currency:`) and reports `quote_changed` instead if it
|
|
123
|
+
# is higher, in another currency, or missing. See #finish_checkout.
|
|
118
124
|
# rubocop:disable Metrics/ParameterLists, Metrics/MethodLength -- all keywords; one per flag, plus
|
|
119
125
|
# injectable collaborators, each assigned to its own ivar
|
|
120
126
|
def initialize(url:, query:, qty: 1, payment_token: nil, yes: false, dry_run: false, product_id: nil,
|
|
121
127
|
auto_open: nil, notify_webhook: nil, handoff_target: nil, confidence_check: nil,
|
|
122
128
|
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
|
|
129
|
+
webmcp_mapping_confirm: nil, autofill: nil, webmcp_autofill_confirm: nil, json: false,
|
|
130
|
+
quote_total: nil, quote_currency: nil)
|
|
124
131
|
# rubocop:enable Metrics/ParameterLists, Metrics/MethodLength
|
|
125
132
|
raw = url.to_s.strip
|
|
126
133
|
@uri = URI.parse(raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}")
|
|
@@ -141,6 +148,8 @@ module Portage
|
|
|
141
148
|
@autofill = autofill
|
|
142
149
|
@webmcp_autofill_confirm = webmcp_autofill_confirm
|
|
143
150
|
@json = json
|
|
151
|
+
@quote_total = quote_total
|
|
152
|
+
@quote_currency = quote_currency
|
|
144
153
|
@webmcp_bridge = webmcp_bridge
|
|
145
154
|
@decisions = {}
|
|
146
155
|
end
|
|
@@ -970,6 +979,8 @@ module Portage
|
|
|
970
979
|
end
|
|
971
980
|
|
|
972
981
|
def finish_checkout(session, source, products, checkout, warnings = [], force_handoff: false)
|
|
982
|
+
return quote_changed_report(source, products, checkout, warnings) if quote_exceeded?(checkout)
|
|
983
|
+
|
|
973
984
|
escalation = decide_escalation(checkout, warnings)
|
|
974
985
|
return escalated_report(source, products, checkout, warnings, escalation) if escalation[:escalate]
|
|
975
986
|
return dry_run_report(source, products, checkout, warnings) if @dry_run
|
|
@@ -979,6 +990,33 @@ module Portage
|
|
|
979
990
|
complete(session, source, products, checkout, warnings)
|
|
980
991
|
end
|
|
981
992
|
|
|
993
|
+
# First in #finish_checkout, ahead of the escalation gates: a
|
|
994
|
+
# `quote_changed` refusal never hands off, since that would spend the
|
|
995
|
+
# quote and open a checkout the person never approved. #complete is
|
|
996
|
+
# the only place this file charges, and it is reached only through
|
|
997
|
+
# #finish_checkout.
|
|
998
|
+
def quote_exceeded?(checkout)
|
|
999
|
+
return false unless @quote_total
|
|
1000
|
+
|
|
1001
|
+
total = checkout_total(checkout)
|
|
1002
|
+
total.nil? || total > @quote_total || checkout["currency"] != @quote_currency
|
|
1003
|
+
end
|
|
1004
|
+
|
|
1005
|
+
def quote_changed_report(source, products, checkout, warnings)
|
|
1006
|
+
total = checkout_total(checkout)
|
|
1007
|
+
checkout_report(source, products, checkout, outcome: "quote_changed", warnings: warnings,
|
|
1008
|
+
quoted_total: @quote_total, quoted_currency: @quote_currency,
|
|
1009
|
+
current_total: total, current_currency: checkout["currency"],
|
|
1010
|
+
message: quote_changed_message(total, checkout["currency"]))
|
|
1011
|
+
end
|
|
1012
|
+
|
|
1013
|
+
def quote_changed_message(total, currency)
|
|
1014
|
+
"The price changed since the quote (was #{quoted_amount(@quote_total, @quote_currency)}, " \
|
|
1015
|
+
"now #{quoted_amount(total, currency)}) — nothing was bought."
|
|
1016
|
+
end
|
|
1017
|
+
|
|
1018
|
+
def quoted_amount(amount, currency) = amount ? Money.format_amount(amount, currency) : "unknown"
|
|
1019
|
+
|
|
982
1020
|
# Hand off vs. keep going is Decisions.escalation's call
|
|
983
1021
|
# (docs/plans/system-one-decision-layer.md § Responsibilities 2): a
|
|
984
1022
|
# literal `requires_escalation` status always escalates. A mismatch
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
require "uri"
|
|
2
2
|
require_relative "config"
|
|
3
3
|
require_relative "setting"
|
|
4
|
+
require_relative "browser_opener"
|
|
4
5
|
|
|
5
6
|
module Portage
|
|
6
7
|
module Cli
|
|
@@ -14,13 +15,11 @@ module Portage
|
|
|
14
15
|
# --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
|
|
15
16
|
# beats ~/.portage/config.json's "auto_open_checkout" (Config).
|
|
16
17
|
#
|
|
17
|
-
#
|
|
18
|
-
#
|
|
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.
|
|
18
|
+
# The open itself is BrowserOpener's array-form `system` shell-out, so
|
|
19
|
+
# a merchant-controlled checkout_url can't inject into a shell.
|
|
23
20
|
class CheckoutHandoff
|
|
21
|
+
include BrowserOpener
|
|
22
|
+
|
|
24
23
|
ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
|
|
25
24
|
CONFIG_KEY = "auto_open_checkout".freeze
|
|
26
25
|
|
|
@@ -47,24 +46,6 @@ module Portage
|
|
|
47
46
|
rescue URI::InvalidURIError
|
|
48
47
|
false
|
|
49
48
|
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
49
|
end
|
|
69
50
|
end
|
|
70
51
|
end
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
require "uri"
|
|
2
|
+
require "securerandom"
|
|
2
3
|
require "portage/ucp"
|
|
3
4
|
require "portage/ucp/client"
|
|
4
5
|
|
|
@@ -62,7 +63,7 @@ module Portage
|
|
|
62
63
|
sourced = source_offers
|
|
63
64
|
return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
|
|
64
65
|
|
|
65
|
-
offers = rank(sourced + probed.flat_map { |store| offers_for(store) })
|
|
66
|
+
offers = rank(sourced + probed.flat_map { |store| offers_for(store) }).map { |o| with_offer_ref(o) }
|
|
66
67
|
report(candidates: candidates, stores: store_summaries(stores), offers: offers,
|
|
67
68
|
message: summary(candidates, stores, offers))
|
|
68
69
|
rescue Portage::Ucp::Client::MissingAgentProfileError
|
|
@@ -78,6 +79,12 @@ module Portage
|
|
|
78
79
|
# the complexity budget.
|
|
79
80
|
def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
|
|
80
81
|
|
|
82
|
+
# A short opaque id `portage buy --offer` resolves from history, so a
|
|
83
|
+
# later step can point at one offer without re-sending its store,
|
|
84
|
+
# product id and query. Added last, after ranking, so it never
|
|
85
|
+
# influences the order.
|
|
86
|
+
def with_offer_ref(offer) = { offer_ref: "of_#{SecureRandom.hex(3)}" }.merge(offer)
|
|
87
|
+
|
|
81
88
|
# --- Step 1: ask the backends who might sell this ---
|
|
82
89
|
|
|
83
90
|
def candidate_origins
|
data/lib/portage/cli/history.rb
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
require "json"
|
|
2
2
|
require "fileutils"
|
|
3
|
+
require "securerandom"
|
|
3
4
|
|
|
4
5
|
module Portage
|
|
5
6
|
module Cli
|
|
@@ -40,11 +41,43 @@ module Portage
|
|
|
40
41
|
|
|
41
42
|
# @param url [String, nil] the store, for a `portage buy` that never
|
|
42
43
|
# reached a checkout there; nil for a cross-store `portage find`.
|
|
43
|
-
|
|
44
|
-
|
|
44
|
+
# @param offers [Array<Hash>] a `find`'s (or `compare`'s) offers, kept
|
|
45
|
+
# so `portage buy --offer REF` can resolve an `offer_ref` later (see
|
|
46
|
+
# #offer). A search that keeps offers also gets a `search_id`, which
|
|
47
|
+
# `portage pick --search` names it by (docs/plans/
|
|
48
|
+
# human-pick-and-approve.md Phase 2).
|
|
49
|
+
# @return [Hash] the saved entry.
|
|
50
|
+
def record_search(query:, offer_count:, message:, url: nil, offers: [])
|
|
51
|
+
kept = offers.empty? ? nil : saved_offers(offers)
|
|
52
|
+
append("searches", { "search_id" => kept && "se_#{SecureRandom.hex(4)}", "query" => query, "url" => url,
|
|
53
|
+
"offer_count" => offer_count, "message" => message, "offers" => kept,
|
|
45
54
|
"at" => @now }.compact)
|
|
46
55
|
end
|
|
47
56
|
|
|
57
|
+
# @param id [String, nil] a `search_id`, or nil/"LAST" for the most
|
|
58
|
+
# recent search that kept offers.
|
|
59
|
+
# @return [Hash, nil]
|
|
60
|
+
def search(id = nil)
|
|
61
|
+
with_offers = store["searches"].select { |search| Array(search["offers"]).any? }
|
|
62
|
+
return with_offers.last if id.nil? || id.casecmp?("last")
|
|
63
|
+
|
|
64
|
+
with_offers.reverse.find { |search| search["search_id"] == id }
|
|
65
|
+
end
|
|
66
|
+
|
|
67
|
+
# @return [Hash, nil] the saved offer for `ref` (store, product_id,
|
|
68
|
+
# title, amount, currency, url, checkout, found_at) plus the catalog
|
|
69
|
+
# `query` to buy it with — the offer's own when it was saved with
|
|
70
|
+
# one (a compare result, searched by the compared product's title),
|
|
71
|
+
# else that of the search that found it — from the most recent
|
|
72
|
+
# search that holds it.
|
|
73
|
+
def offer(ref)
|
|
74
|
+
store["searches"].reverse_each do |search|
|
|
75
|
+
found = Array(search["offers"]).find { |saved| saved["offer_ref"] == ref }
|
|
76
|
+
return { "query" => search["query"] }.merge(found) if found
|
|
77
|
+
end
|
|
78
|
+
nil
|
|
79
|
+
end
|
|
80
|
+
|
|
48
81
|
def purchases(limit: MAX_ENTRIES) = store["purchases"].last(limit)
|
|
49
82
|
|
|
50
83
|
def searches(limit: MAX_ENTRIES) = store["searches"].last(limit)
|
|
@@ -58,6 +91,18 @@ module Portage
|
|
|
58
91
|
|
|
59
92
|
private
|
|
60
93
|
|
|
94
|
+
# Takes a report's symbol-keyed offers or already-saved string-keyed
|
|
95
|
+
# ones (`pick` re-saves the offer it compared alongside the results).
|
|
96
|
+
def saved_offers(offers)
|
|
97
|
+
offers.map do |offer|
|
|
98
|
+
offer = offer.transform_keys(&:to_s)
|
|
99
|
+
saved = { "offer_ref" => offer["offer_ref"], "store" => offer["store"], "product_id" => offer["product_id"],
|
|
100
|
+
"title" => offer["title"], "amount" => offer["amount"], "currency" => offer["currency"],
|
|
101
|
+
"url" => offer["url"], "checkout" => offer["checkout"], "found_at" => offer["found_at"] || @now }
|
|
102
|
+
offer["query"] ? saved.merge("query" => offer["query"]) : saved
|
|
103
|
+
end
|
|
104
|
+
end
|
|
105
|
+
|
|
61
106
|
def append(kind, entry)
|
|
62
107
|
store[kind] = (store[kind] + [entry]).last(MAX_ENTRIES)
|
|
63
108
|
write
|