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
data/lib/portage/cli.rb
CHANGED
|
@@ -16,6 +16,14 @@ require_relative "cli/buy"
|
|
|
16
16
|
require_relative "cli/find"
|
|
17
17
|
require_relative "cli/compare"
|
|
18
18
|
require_relative "cli/history"
|
|
19
|
+
require_relative "cli/quotes"
|
|
20
|
+
require_relative "cli/money"
|
|
21
|
+
require_relative "cli/human_prompt"
|
|
22
|
+
require_relative "cli/product_page"
|
|
23
|
+
require_relative "cli/approval_policy"
|
|
24
|
+
require_relative "cli/offer_choice"
|
|
25
|
+
require_relative "cli/pick"
|
|
26
|
+
require_relative "cli/approve"
|
|
19
27
|
require_relative "cli/payment_methods"
|
|
20
28
|
require_relative "cli/proxy_settings"
|
|
21
29
|
require_relative "cli/handoff_only"
|
|
@@ -42,10 +50,15 @@ module Portage
|
|
|
42
50
|
[--handoff-target default|print|profile|agent:NAME]
|
|
43
51
|
[--decision-backend jev|laya] [--min-confidence N] [--json]
|
|
44
52
|
[--wait [--wait-timeout DURATION|off]]
|
|
53
|
+
portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
|
|
54
|
+
portage buy --quote QUOTE_ID --yes [--json] ...
|
|
45
55
|
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
|
|
46
56
|
portage find --query "..." [--max-price N] [--limit N] [--json]
|
|
47
57
|
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
|
|
48
58
|
[--max-price N] [--json]
|
|
59
|
+
portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
|
|
60
|
+
[--choose REF | --compare REF | --view REF]
|
|
61
|
+
portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
|
|
49
62
|
portage history [list] [--purchases|--searches] [--limit N] [--json]
|
|
50
63
|
portage history clear [--purchases|--searches]
|
|
51
64
|
portage payment list [--json]
|
|
@@ -60,6 +73,7 @@ module Portage
|
|
|
60
73
|
[--rolling-cap N --rolling-window-seconds N --currency CUR]
|
|
61
74
|
[--velocity-count N --velocity-window-seconds N]
|
|
62
75
|
[--allow HOST ...] [--clear-allowlist]
|
|
76
|
+
[--require-approval person|any|off] (lowering asks at a terminal)
|
|
63
77
|
portage orders reconcile [--checkout ID] [--json]
|
|
64
78
|
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
65
79
|
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
|
|
@@ -86,6 +100,7 @@ module Portage
|
|
|
86
100
|
USAGE
|
|
87
101
|
|
|
88
102
|
COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
|
|
103
|
+
"pick" => :run_pick, "approve" => :run_approve,
|
|
89
104
|
"history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
|
|
90
105
|
"orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
|
|
91
106
|
"doctor" => :run_doctor,
|
|
@@ -142,18 +157,26 @@ module Portage
|
|
|
142
157
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
143
158
|
|
|
144
159
|
report = Find.new(**options).call
|
|
145
|
-
record_find(report)
|
|
160
|
+
report = with_search_id(report, record_find(report))
|
|
146
161
|
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
147
162
|
report[:offers].any? ? 0 : 1
|
|
148
163
|
end
|
|
149
164
|
private_class_method :run_find
|
|
150
165
|
|
|
166
|
+
# @return [Hash, nil] the saved search entry.
|
|
151
167
|
def self.record_find(report)
|
|
152
168
|
History.new.record_search(query: report[:query], offer_count: report[:offers].length,
|
|
153
|
-
message: report[:message])
|
|
169
|
+
message: report[:message], offers: report[:offers])
|
|
154
170
|
end
|
|
155
171
|
private_class_method :record_find
|
|
156
172
|
|
|
173
|
+
# docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
|
|
174
|
+
# for `portage pick --search`. Additive; absent when nothing was saved.
|
|
175
|
+
def self.with_search_id(report, entry)
|
|
176
|
+
entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
|
|
177
|
+
end
|
|
178
|
+
private_class_method :with_search_id
|
|
179
|
+
|
|
157
180
|
def self.parse_find_options(argv)
|
|
158
181
|
opts = {}
|
|
159
182
|
find_option_parser(opts).parse!(argv)
|
|
@@ -195,16 +218,26 @@ module Portage
|
|
|
195
218
|
return 1 unless apply_proxy_settings(options.delete(:proxy))
|
|
196
219
|
|
|
197
220
|
report = Compare.new(origin_url: url, **options).call
|
|
198
|
-
|
|
199
|
-
# query string names the compare so `portage history list` doesn't
|
|
200
|
-
# read it as a plain text search for the origin product's own title.
|
|
201
|
-
History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
|
|
202
|
-
offer_count: report[:offers].length, message: report[:message])
|
|
221
|
+
report = with_search_id(report, record_compare(url, options[:origin_product_id], report))
|
|
203
222
|
puts json ? JSON.pretty_generate(report) : format_compare(report)
|
|
204
223
|
report[:offers].any? ? 0 : 1
|
|
205
224
|
end
|
|
206
225
|
private_class_method :run_compare
|
|
207
226
|
|
|
227
|
+
# Recorded as a search, not a purchase — compare never checks out. The
|
|
228
|
+
# query string names the compare so `portage history list` doesn't
|
|
229
|
+
# read it as a plain text search for the origin product's own title.
|
|
230
|
+
# Its offers are kept with their refs, like `find`'s
|
|
231
|
+
# (docs/plans/human-pick-and-approve.md Phase 2), each carrying the
|
|
232
|
+
# catalog query compare searched with (the origin product's title), so
|
|
233
|
+
# `buy --offer REF` searches for the product, not for "compare: ...".
|
|
234
|
+
def self.record_compare(url, product_id, report)
|
|
235
|
+
offers = report[:offers].map { |offer| offer.merge(query: report[:query]) }
|
|
236
|
+
History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
|
|
237
|
+
message: report[:message], offers: offers)
|
|
238
|
+
end
|
|
239
|
+
private_class_method :record_compare
|
|
240
|
+
|
|
208
241
|
def self.parse_compare_options(argv)
|
|
209
242
|
url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
210
243
|
opts = { identity: [] }
|
|
@@ -245,11 +278,21 @@ module Portage
|
|
|
245
278
|
|
|
246
279
|
parsed[:handoff_target] = handoff_target(parsed, url)
|
|
247
280
|
return 1 unless parsed[:handoff_target]
|
|
281
|
+
|
|
282
|
+
dispatch_buy(parsed, url)
|
|
283
|
+
end
|
|
284
|
+
private_class_method :run_buy
|
|
285
|
+
|
|
286
|
+
# A saved quote or offer names its own store; otherwise a url does, and
|
|
287
|
+
# with neither the search picks one.
|
|
288
|
+
def self.dispatch_buy(parsed, url)
|
|
289
|
+
return buy_from_quote(parsed) if parsed[:quote]
|
|
290
|
+
return buy_from_offer(parsed) if parsed[:offer]
|
|
248
291
|
return execute_buy(parsed, url) if url
|
|
249
292
|
|
|
250
293
|
buy_from_search(parsed)
|
|
251
294
|
end
|
|
252
|
-
private_class_method :
|
|
295
|
+
private_class_method :dispatch_buy
|
|
253
296
|
|
|
254
297
|
# Built and validated up front, same posture as #confidence_check — an
|
|
255
298
|
# unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
|
|
@@ -267,48 +310,205 @@ module Portage
|
|
|
267
310
|
# caller picks. `--yes` alone deliberately isn't enough to get here —
|
|
268
311
|
# without a URL the merchant would have been chosen by a search ranker
|
|
269
312
|
# rather than by a person, so either `--store` (handled above) or an
|
|
270
|
-
# interactive pick has to name it.
|
|
313
|
+
# interactive pick has to name it. Runs with no terminal (or under
|
|
314
|
+
# --json) list the offers and stop.
|
|
271
315
|
def self.buy_from_search(parsed)
|
|
272
316
|
report = Find.new(**parsed[:find]).call
|
|
273
|
-
record_find(report)
|
|
317
|
+
report = with_search_id(report, record_find(report))
|
|
274
318
|
offer = pick_offer(report, parsed[:json])
|
|
275
319
|
return report[:offers].any? ? 0 : 1 unless offer
|
|
276
320
|
|
|
321
|
+
parsed[:offer_ref] = offer[:offer_ref]
|
|
322
|
+
parsed[:page] = { title: offer[:title], url: offer[:url] }
|
|
277
323
|
execute_buy(parsed, offer[:store], product_id: offer[:product_id])
|
|
278
324
|
end
|
|
279
325
|
private_class_method :buy_from_search
|
|
280
326
|
|
|
327
|
+
# `--offer REF`: the store, product and query come from the saved
|
|
328
|
+
# `find` that produced the ref, as if they'd been passed as flags.
|
|
329
|
+
def self.buy_from_offer(parsed)
|
|
330
|
+
offer = History.new.offer(parsed[:offer])
|
|
331
|
+
unless offer
|
|
332
|
+
invalid_buy_option("No saved offer #{parsed[:offer]} — run `portage find` again.",
|
|
333
|
+
url: nil, json: parsed[:json], outcome: "offer_not_found")
|
|
334
|
+
return 1
|
|
335
|
+
end
|
|
336
|
+
|
|
337
|
+
parsed[:offer_ref] = parsed[:offer]
|
|
338
|
+
parsed[:page] = { title: offer["title"], url: offer["url"] }
|
|
339
|
+
parsed[:buy][:query] = offer["query"].to_s
|
|
340
|
+
execute_buy(parsed, offer["store"], product_id: offer["product_id"])
|
|
341
|
+
end
|
|
342
|
+
private_class_method :buy_from_offer
|
|
343
|
+
|
|
344
|
+
# `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
|
|
345
|
+
# quoted total as a cap and refuses (`quote_changed`) if the real
|
|
346
|
+
# checkout costs more, so the person's approval always covers the total
|
|
347
|
+
# that gets charged. The quote is spent by #settle_quote, once the run
|
|
348
|
+
# purchases or hands off.
|
|
349
|
+
#
|
|
350
|
+
# docs/plans/human-pick-and-approve.md Phase 2: a real run (`--yes`, not
|
|
351
|
+
# `--dry-run`) of a quote that isn't approved enough for
|
|
352
|
+
# `--require-approval` is refused with `needs_approval` before Buy
|
|
353
|
+
# runs, so it never charges and never hands off.
|
|
354
|
+
def self.buy_from_quote(parsed)
|
|
355
|
+
quote = usable_quote(parsed)
|
|
356
|
+
return 1 unless quote
|
|
357
|
+
|
|
358
|
+
level = ApprovalPolicy.level
|
|
359
|
+
if real_run?(parsed) && !ApprovalPolicy.satisfied?(quote, level)
|
|
360
|
+
return refuse_unapproved_quote(parsed, quote, level)
|
|
361
|
+
end
|
|
362
|
+
|
|
363
|
+
parsed[:quote_record] = quote
|
|
364
|
+
buy = parsed[:buy]
|
|
365
|
+
buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
|
|
366
|
+
buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
|
|
367
|
+
execute_buy(parsed, quote["store"])
|
|
368
|
+
end
|
|
369
|
+
private_class_method :buy_from_quote
|
|
370
|
+
|
|
371
|
+
def self.usable_quote(parsed)
|
|
372
|
+
quote = Quotes.new.find(parsed[:quote])
|
|
373
|
+
unless quote
|
|
374
|
+
return invalid_buy_option("No saved quote #{parsed[:quote]} — run `portage buy ... --dry-run --json` " \
|
|
375
|
+
"for a new one.", url: nil, json: parsed[:json], outcome: "quote_not_found")
|
|
376
|
+
end
|
|
377
|
+
return quote unless quote["used_at"]
|
|
378
|
+
|
|
379
|
+
invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
|
|
380
|
+
url: quote["store"], json: parsed[:json], outcome: "quote_used")
|
|
381
|
+
end
|
|
382
|
+
private_class_method :usable_quote
|
|
383
|
+
|
|
384
|
+
def self.refuse_unapproved_quote(parsed, quote, level)
|
|
385
|
+
summary = Approve.summary(quote)
|
|
386
|
+
report = { url: quote["store"], checkout_url: nil, products: [], warnings: [], source: "none", browse: false,
|
|
387
|
+
checkout: false }.merge(Approve.needs_approval(summary, message: approval_message(summary, level)))
|
|
388
|
+
print_buy_report(report, nil, parsed[:json])
|
|
389
|
+
buy_exit_code(report)
|
|
390
|
+
end
|
|
391
|
+
private_class_method :refuse_unapproved_quote
|
|
392
|
+
|
|
393
|
+
# With a terminal (and no --json) the person picks there, through the
|
|
394
|
+
# same HumanPrompt numbered pick `portage pick` uses; otherwise the
|
|
395
|
+
# offers are printed and nothing is bought.
|
|
281
396
|
def self.pick_offer(report, json)
|
|
282
|
-
|
|
283
|
-
|
|
284
|
-
|
|
397
|
+
prompt = HumanPrompt.new(json: json)
|
|
398
|
+
unless prompt.tty? && report[:offers].any?
|
|
399
|
+
puts json ? JSON.pretty_generate(report) : format_find(report)
|
|
400
|
+
return nil
|
|
401
|
+
end
|
|
285
402
|
|
|
286
|
-
prompt_for_offer(report
|
|
403
|
+
prompt_for_offer(prompt, report)
|
|
287
404
|
end
|
|
288
405
|
private_class_method :pick_offer
|
|
289
406
|
|
|
290
|
-
def self.prompt_for_offer(
|
|
291
|
-
|
|
292
|
-
|
|
293
|
-
|
|
294
|
-
|
|
295
|
-
offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
|
|
407
|
+
def self.prompt_for_offer(prompt, report)
|
|
408
|
+
prompt.say(report[:message].to_s)
|
|
409
|
+
choices = report[:offers].map { |offer| OfferChoice.for(offer) }
|
|
410
|
+
index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
|
|
411
|
+
index && report[:offers][index]
|
|
296
412
|
end
|
|
297
413
|
private_class_method :prompt_for_offer
|
|
298
414
|
|
|
415
|
+
# docs/plans/human-pick-and-approve.md Phase 2: under
|
|
416
|
+
# `--require-approval any|person` a real `--yes` run with no approved
|
|
417
|
+
# `--quote` is run as a dry run instead — never charged, never handed
|
|
418
|
+
# off — which prices it and saves a quote, and the report becomes
|
|
419
|
+
# `needs_approval` naming that quote and the next steps. Done here, not
|
|
420
|
+
# in Buy, so Buy's library callers aren't governed by the CLI policy.
|
|
299
421
|
def self.execute_buy(parsed, url, product_id: nil)
|
|
300
|
-
|
|
301
|
-
|
|
302
|
-
options
|
|
303
|
-
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
422
|
+
gated = approval_gate?(parsed)
|
|
423
|
+
parsed[:buy] = parsed[:buy].merge(yes: false, dry_run: true) if gated
|
|
424
|
+
options = buy_options(parsed, url, product_id: product_id)
|
|
304
425
|
report = Buy.new(**options).call
|
|
305
426
|
record_buy(report, options[:query])
|
|
427
|
+
report = settle_quote(report, parsed, options)
|
|
428
|
+
report = needs_approval_report(report) if gated
|
|
306
429
|
result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
|
|
307
430
|
print_buy_report(report, result, parsed[:json])
|
|
308
|
-
report
|
|
431
|
+
buy_exit_code(report)
|
|
309
432
|
end
|
|
310
433
|
private_class_method :execute_buy
|
|
311
434
|
|
|
435
|
+
def self.buy_options(parsed, url, product_id: nil)
|
|
436
|
+
options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
|
|
437
|
+
handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
|
|
438
|
+
options[:product_id] ||= product_id
|
|
439
|
+
options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
|
|
440
|
+
options
|
|
441
|
+
end
|
|
442
|
+
private_class_method :buy_options
|
|
443
|
+
|
|
444
|
+
# `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
|
|
445
|
+
# did what it could and is waiting on the person.
|
|
446
|
+
def self.buy_exit_code(report)
|
|
447
|
+
report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
|
|
448
|
+
end
|
|
449
|
+
private_class_method :buy_exit_code
|
|
450
|
+
|
|
451
|
+
def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
|
|
452
|
+
private_class_method :real_run?
|
|
453
|
+
|
|
454
|
+
# An approved quote already passed ApprovalPolicy in #buy_from_quote.
|
|
455
|
+
def self.approval_gate?(parsed)
|
|
456
|
+
real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
|
|
457
|
+
end
|
|
458
|
+
private_class_method :approval_gate?
|
|
459
|
+
|
|
460
|
+
# A gated run that got as far as a priced checkout saved a quote; one
|
|
461
|
+
# that didn't (no match, a dead end) is reported as it is — nothing
|
|
462
|
+
# was bought either way.
|
|
463
|
+
def self.needs_approval_report(report)
|
|
464
|
+
quote = report[:quote_id] && Quotes.new.find(report[:quote_id])
|
|
465
|
+
return report unless quote
|
|
466
|
+
|
|
467
|
+
summary = Approve.summary(quote)
|
|
468
|
+
report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
|
|
469
|
+
end
|
|
470
|
+
private_class_method :needs_approval_report
|
|
471
|
+
|
|
472
|
+
def self.approval_message(summary, level)
|
|
473
|
+
id = summary[:quote_id]
|
|
474
|
+
held = if summary[:approved_by]
|
|
475
|
+
"Quote #{id} was approved by #{summary[:approved_by]}, which require_approval #{level} doesn't accept."
|
|
476
|
+
else
|
|
477
|
+
"Quote #{id} (#{Approve.describe(summary)}) needs approval first (require_approval: #{level})."
|
|
478
|
+
end
|
|
479
|
+
how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
|
|
480
|
+
"Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
|
|
481
|
+
end
|
|
482
|
+
private_class_method :approval_message
|
|
483
|
+
|
|
484
|
+
# A dry run saves a quote and reports its `quote_id`. A run of a saved
|
|
485
|
+
# quote spends it once it purchases or hands off; any other outcome
|
|
486
|
+
# (needs_confirmation, a dry run, an error) leaves it usable.
|
|
487
|
+
def self.settle_quote(report, parsed, options)
|
|
488
|
+
quote = parsed[:quote_record]
|
|
489
|
+
if quote
|
|
490
|
+
Quotes.new.consume(quote["quote_id"]) if report[:outcome] == "purchased" || report[:handoff]
|
|
491
|
+
return report[:outcome] == "quote_changed" ? report.merge(quote_id: quote["quote_id"]) : report
|
|
492
|
+
end
|
|
493
|
+
return report unless report[:outcome] == "dry_run"
|
|
494
|
+
|
|
495
|
+
saved = Quotes.new.create(offer_ref: parsed[:offer_ref], store: report[:url],
|
|
496
|
+
product_id: options[:product_id], query: options[:query], qty: options[:qty],
|
|
497
|
+
total: report_total(report), currency: report[:currency],
|
|
498
|
+
**quote_page(report, parsed))
|
|
499
|
+
saved ? report.merge(quote_id: saved["quote_id"]) : report
|
|
500
|
+
end
|
|
501
|
+
private_class_method :settle_quote
|
|
502
|
+
|
|
503
|
+
# What `portage approve` shows (docs/plans/human-pick-and-approve.md
|
|
504
|
+
# Phase 2): the title of what's in the checkout, else the picked
|
|
505
|
+
# offer's, and the offer's product page when the buy came from one.
|
|
506
|
+
def self.quote_page(report, parsed)
|
|
507
|
+
page = parsed[:page] || {}
|
|
508
|
+
{ title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
|
|
509
|
+
end
|
|
510
|
+
private_class_method :quote_page
|
|
511
|
+
|
|
312
512
|
# docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
|
|
313
513
|
# profile` gives `portage buy` a browser of its own — the Portage
|
|
314
514
|
# profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
|
|
@@ -458,14 +658,14 @@ module Portage
|
|
|
458
658
|
# any other, with outcome `invalid_option`, so an agent loop reading
|
|
459
659
|
# stdout gets JSON rather than nothing and a line on stderr.
|
|
460
660
|
# @return [nil]
|
|
461
|
-
def self.invalid_buy_option(message, url:, json:)
|
|
661
|
+
def self.invalid_buy_option(message, url:, json:, outcome: "invalid_option")
|
|
462
662
|
unless json
|
|
463
663
|
warn message
|
|
464
664
|
return nil
|
|
465
665
|
end
|
|
466
666
|
|
|
467
667
|
puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
|
|
468
|
-
outcome:
|
|
668
|
+
outcome: outcome, browse: false, checkout: false, message: message)
|
|
469
669
|
nil
|
|
470
670
|
end
|
|
471
671
|
private_class_method :invalid_buy_option
|
|
@@ -510,7 +710,7 @@ module Portage
|
|
|
510
710
|
parser.parse!(argv)
|
|
511
711
|
url = reinterpret_bare_query(url, buy, parsed)
|
|
512
712
|
buy[:query] ||= ""
|
|
513
|
-
return parsed if url
|
|
713
|
+
return parsed if buy_target?(url, buy, parsed)
|
|
514
714
|
|
|
515
715
|
warn USAGE
|
|
516
716
|
nil
|
|
@@ -519,6 +719,11 @@ module Portage
|
|
|
519
719
|
end
|
|
520
720
|
private_class_method :parse_buy_options
|
|
521
721
|
|
|
722
|
+
def self.buy_target?(url, buy, parsed)
|
|
723
|
+
url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
|
|
724
|
+
end
|
|
725
|
+
private_class_method :buy_target?
|
|
726
|
+
|
|
522
727
|
# Bare arg is normally the store URL (`portage buy <url> --query "..."`),
|
|
523
728
|
# but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
|
|
524
729
|
# a URL/domain — means the same thing as `portage buy --query "coffee"`:
|
|
@@ -583,6 +788,8 @@ module Portage
|
|
|
583
788
|
# and the catalog search once a store is settled, so it's registered once
|
|
584
789
|
# here rather than twice on the same parser.
|
|
585
790
|
def self.add_search_options(parser, buy, parsed)
|
|
791
|
+
parser.on("--offer REF") { |v| parsed[:offer] = v }
|
|
792
|
+
parser.on("--quote QUOTE_ID") { |v| parsed[:quote] = v }
|
|
586
793
|
parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
|
|
587
794
|
parser.on("--store URL") { |v| parsed[:store] = v }
|
|
588
795
|
parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
|
|
@@ -590,6 +797,94 @@ module Portage
|
|
|
590
797
|
end
|
|
591
798
|
private_class_method :add_search_options
|
|
592
799
|
|
|
800
|
+
# --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
|
|
801
|
+
|
|
802
|
+
# Outcomes a pick/approve run exits 0 on: an answer, a page shown, or a
|
|
803
|
+
# question handed to the agent. Everything else (cancelled, not found,
|
|
804
|
+
# used, refused, no terminal) exits 1.
|
|
805
|
+
PROMPT_OK_OUTCOMES = %w[picked approved viewed needs_pick needs_approval].freeze
|
|
806
|
+
|
|
807
|
+
def self.run_pick(argv)
|
|
808
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
809
|
+
parser.on("--search ID") { |v| o[:search] = v }
|
|
810
|
+
parser.on("--choose REF") { |v| o[:choose] = v }
|
|
811
|
+
parser.on("--compare REF") { |v| o[:compare] = v }
|
|
812
|
+
parser.on("--view REF") { |v| o[:view] = v }
|
|
813
|
+
end
|
|
814
|
+
return 1 unless opts
|
|
815
|
+
|
|
816
|
+
pick = Pick.new(prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]), comparer: method(:compare_offer),
|
|
817
|
+
**opts.slice(:search, :choose, :view, :compare))
|
|
818
|
+
print_prompt_result(pick.call, opts[:json])
|
|
819
|
+
end
|
|
820
|
+
private_class_method :run_pick
|
|
821
|
+
|
|
822
|
+
def self.run_approve(argv)
|
|
823
|
+
quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
|
|
824
|
+
opts = parse_prompt_options(argv) do |parser, o|
|
|
825
|
+
parser.on("--relayed-yes") { o[:relayed_yes] = true }
|
|
826
|
+
parser.on("--view") { o[:view] = true }
|
|
827
|
+
end
|
|
828
|
+
return 1 unless opts
|
|
829
|
+
return prompt_usage(opts[:json], "portage approve needs a QUOTE_ID.") unless quote_id
|
|
830
|
+
|
|
831
|
+
approve = Approve.new(quote_id: quote_id, prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]),
|
|
832
|
+
**opts.slice(:relayed_yes, :view))
|
|
833
|
+
print_prompt_result(approve.call, opts[:json])
|
|
834
|
+
end
|
|
835
|
+
private_class_method :run_approve
|
|
836
|
+
|
|
837
|
+
# `--via`/`--json` for both commands, plus whatever the block adds.
|
|
838
|
+
# @return [Hash, nil] nil on a bad flag (already reported).
|
|
839
|
+
def self.parse_prompt_options(argv)
|
|
840
|
+
opts = { via: "auto" }
|
|
841
|
+
json = argv.include?("--json")
|
|
842
|
+
OptionParser.new do |parser|
|
|
843
|
+
parser.on("--via SURFACE", HumanPrompt::VIAS) { |v| opts[:via] = v }
|
|
844
|
+
parser.on("--json") { opts[:json] = true }
|
|
845
|
+
yield parser, opts
|
|
846
|
+
end.parse!(argv)
|
|
847
|
+
opts
|
|
848
|
+
rescue OptionParser::ParseError => e
|
|
849
|
+
prompt_usage(json, e.message)
|
|
850
|
+
nil
|
|
851
|
+
end
|
|
852
|
+
private_class_method :parse_prompt_options
|
|
853
|
+
|
|
854
|
+
def self.prompt_usage(json, message)
|
|
855
|
+
json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
|
|
856
|
+
1
|
|
857
|
+
end
|
|
858
|
+
private_class_method :prompt_usage
|
|
859
|
+
|
|
860
|
+
# Pick's "Compare an offer across stores": the same Compare run
|
|
861
|
+
# `portage compare` does, from the saved offer's store and product.
|
|
862
|
+
def self.compare_offer(offer)
|
|
863
|
+
return { offers: [], message: "Couldn't compare — see the proxy error above." } unless apply_proxy_settings({})
|
|
864
|
+
|
|
865
|
+
Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
|
|
866
|
+
end
|
|
867
|
+
private_class_method :compare_offer
|
|
868
|
+
|
|
869
|
+
def self.print_prompt_result(result, json)
|
|
870
|
+
puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
|
|
871
|
+
PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
|
|
872
|
+
end
|
|
873
|
+
private_class_method :print_prompt_result
|
|
874
|
+
|
|
875
|
+
def self.format_prompt_result(result)
|
|
876
|
+
lines = ["[#{result[:outcome]}] #{result[:message]}"]
|
|
877
|
+
Array(result[:choices]).each_with_index { |choice, index| lines << " #{index + 1}. #{choice_line(choice)}" }
|
|
878
|
+
lines << " #{approval_line(result[:summary])}" if result[:summary]
|
|
879
|
+
lines.join("\n")
|
|
880
|
+
end
|
|
881
|
+
private_class_method :format_prompt_result
|
|
882
|
+
|
|
883
|
+
def self.choice_line(choice)
|
|
884
|
+
[choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
|
|
885
|
+
end
|
|
886
|
+
private_class_method :choice_line
|
|
887
|
+
|
|
593
888
|
# --- history ---
|
|
594
889
|
|
|
595
890
|
def self.run_history(argv)
|
|
@@ -783,53 +1078,110 @@ module Portage
|
|
|
783
1078
|
end
|
|
784
1079
|
private_class_method :run_policy
|
|
785
1080
|
|
|
1081
|
+
# `require_approval` is always shown at its effective value, the
|
|
1082
|
+
# default included, so "what does `--yes` need right now" is never a
|
|
1083
|
+
# guess.
|
|
786
1084
|
def self.run_policy_show(argv)
|
|
787
1085
|
json = false
|
|
788
1086
|
OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
|
|
789
|
-
policy = Portage::Ucp::Policy.load
|
|
790
|
-
|
|
1087
|
+
policy = Portage::Ucp::Policy.load
|
|
1088
|
+
effective = policy.to_h.merge(ApprovalPolicy::KEY => ApprovalPolicy.level(policy))
|
|
1089
|
+
puts json ? JSON.pretty_generate(effective) : format_policy(policy)
|
|
791
1090
|
0
|
|
792
1091
|
end
|
|
793
1092
|
private_class_method :run_policy_show
|
|
794
1093
|
|
|
795
1094
|
def self.format_policy(policy)
|
|
796
|
-
|
|
797
|
-
|
|
798
|
-
|
|
1095
|
+
spending = policy.to_h.except(ApprovalPolicy::KEY)
|
|
1096
|
+
body = spending.empty? ? "(no policy configured — every spending check passes)" : JSON.pretty_generate(spending)
|
|
1097
|
+
default = " (default)" unless ApprovalPolicy.configured?(policy)
|
|
1098
|
+
"#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
|
|
799
1099
|
end
|
|
800
1100
|
private_class_method :format_policy
|
|
801
1101
|
|
|
802
1102
|
def self.parse_policy_set_options(argv)
|
|
803
1103
|
opts = { allow: [] }
|
|
804
1104
|
OptionParser.new do |parser|
|
|
805
|
-
parser
|
|
806
|
-
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
807
|
-
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
808
|
-
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1105
|
+
add_policy_cap_options(parser, opts)
|
|
809
1106
|
parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
|
|
810
1107
|
parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
|
|
811
1108
|
parser.on("--allow HOST") { |v| opts[:allow] << v }
|
|
812
1109
|
parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
|
|
1110
|
+
parser.on("--require-approval LEVEL", ApprovalPolicy::LEVELS) { |v| opts[:require_approval] = v }
|
|
813
1111
|
end.parse!(argv)
|
|
814
1112
|
opts
|
|
815
1113
|
end
|
|
816
1114
|
private_class_method :parse_policy_set_options
|
|
817
1115
|
|
|
1116
|
+
def self.add_policy_cap_options(parser, opts)
|
|
1117
|
+
parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
|
|
1118
|
+
parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
|
|
1119
|
+
parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
|
|
1120
|
+
parser.on("--currency CUR") { |v| opts[:currency] = v }
|
|
1121
|
+
end
|
|
1122
|
+
private_class_method :add_policy_cap_options
|
|
1123
|
+
|
|
818
1124
|
# Each `--*` group is applied independently and only when its required
|
|
819
1125
|
# fields are present — `portage policy set --allow shop.example.com`
|
|
820
1126
|
# touches only the allowlist, leaving caps/velocity untouched, so caps
|
|
821
1127
|
# and the allowlist can be configured in separate invocations.
|
|
1128
|
+
#
|
|
1129
|
+
# `--require-approval` goes first: a lowering the person doesn't confirm
|
|
1130
|
+
# at the terminal refuses the whole invocation, so nothing else in it
|
|
1131
|
+
# changes either.
|
|
822
1132
|
def self.run_policy_set(argv)
|
|
823
1133
|
opts = parse_policy_set_options(argv)
|
|
824
1134
|
policy = Portage::Ucp::Policy.load
|
|
1135
|
+
return 1 unless require_approval_applied?(policy, opts[:require_approval])
|
|
1136
|
+
|
|
825
1137
|
set_policy_cap(policy, opts)
|
|
826
1138
|
set_policy_velocity(policy, opts)
|
|
827
1139
|
set_policy_allowlist(policy, opts)
|
|
828
|
-
puts format_policy(policy
|
|
1140
|
+
puts format_policy(policy)
|
|
829
1141
|
0
|
|
1142
|
+
rescue OptionParser::ParseError => e
|
|
1143
|
+
warn "#{e.message}\n#{USAGE}"
|
|
1144
|
+
1
|
|
830
1145
|
end
|
|
831
1146
|
private_class_method :run_policy_set
|
|
832
1147
|
|
|
1148
|
+
# docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
|
|
1149
|
+
# setting the same one) needs nothing; lowering it (person -> any/off,
|
|
1150
|
+
# any -> off) needs a yes typed on the tty, since an agent with a shell
|
|
1151
|
+
# can run `policy set` but can't type on /dev/tty. No terminal, no
|
|
1152
|
+
# change.
|
|
1153
|
+
# @return [Boolean] false when a lowering was refused (nothing changed).
|
|
1154
|
+
def self.require_approval_applied?(policy, level)
|
|
1155
|
+
return true unless level
|
|
1156
|
+
|
|
1157
|
+
current = ApprovalPolicy.level(policy)
|
|
1158
|
+
return false if ApprovalPolicy.lowering?(current, level) && !confirm_lowering(current, level)
|
|
1159
|
+
|
|
1160
|
+
policy.set(ApprovalPolicy::KEY, level)
|
|
1161
|
+
true
|
|
1162
|
+
end
|
|
1163
|
+
private_class_method :require_approval_applied?
|
|
1164
|
+
|
|
1165
|
+
def self.confirm_lowering(current, level)
|
|
1166
|
+
prompt = HumanPrompt.new(via: "tty")
|
|
1167
|
+
return true if prompt.confirm("Lower require_approval from #{current} to #{level}? #{lowering_effect(level)}")
|
|
1168
|
+
|
|
1169
|
+
warn "require_approval left at #{current}."
|
|
1170
|
+
false
|
|
1171
|
+
rescue HumanPrompt::NoTerminal
|
|
1172
|
+
warn "Lowering require_approval (#{current} -> #{level}) needs a yes typed at a terminal, and there's no " \
|
|
1173
|
+
"terminal here — nothing changed. Run it yourself from a terminal."
|
|
1174
|
+
false
|
|
1175
|
+
end
|
|
1176
|
+
private_class_method :confirm_lowering
|
|
1177
|
+
|
|
1178
|
+
def self.lowering_effect(level)
|
|
1179
|
+
return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
|
|
1180
|
+
|
|
1181
|
+
"An agent relaying your yes would then be enough to buy."
|
|
1182
|
+
end
|
|
1183
|
+
private_class_method :lowering_effect
|
|
1184
|
+
|
|
833
1185
|
def self.set_policy_cap(policy, opts)
|
|
834
1186
|
if opts[:per_transaction_cap]
|
|
835
1187
|
policy.set("per_transaction_cap",
|
|
@@ -1454,6 +1806,7 @@ module Portage
|
|
|
1454
1806
|
lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
|
|
1455
1807
|
report[:products].each { |p| lines << " - #{product_line(p)}" }
|
|
1456
1808
|
lines.concat(format_checkout(report))
|
|
1809
|
+
lines.concat(format_quote(report))
|
|
1457
1810
|
lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
|
|
1458
1811
|
lines.concat(format_handoff(report[:handoff])) if report[:handoff]
|
|
1459
1812
|
lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
|
|
@@ -1461,6 +1814,18 @@ module Portage
|
|
|
1461
1814
|
end
|
|
1462
1815
|
private_class_method :format_report
|
|
1463
1816
|
|
|
1817
|
+
def self.format_quote(report)
|
|
1818
|
+
lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
|
|
1819
|
+
lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
|
|
1820
|
+
lines
|
|
1821
|
+
end
|
|
1822
|
+
private_class_method :format_quote
|
|
1823
|
+
|
|
1824
|
+
def self.approval_line(summary)
|
|
1825
|
+
[Approve.describe(summary), summary[:url]].compact.join(" — ")
|
|
1826
|
+
end
|
|
1827
|
+
private_class_method :approval_line
|
|
1828
|
+
|
|
1464
1829
|
# What the checkout holds, as opposed to the search results above it,
|
|
1465
1830
|
# and where it differs from the request.
|
|
1466
1831
|
def self.format_checkout(report)
|
|
@@ -1493,6 +1858,7 @@ module Portage
|
|
|
1493
1858
|
def self.format_find(report)
|
|
1494
1859
|
lines = [report[:message].to_s]
|
|
1495
1860
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
|
|
1861
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
1496
1862
|
lines.join("\n")
|
|
1497
1863
|
end
|
|
1498
1864
|
private_class_method :format_find
|
|
@@ -1500,6 +1866,7 @@ module Portage
|
|
|
1500
1866
|
def self.offer_line(offer)
|
|
1501
1867
|
parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
1502
1868
|
parts << "browse only" unless offer[:checkout]
|
|
1869
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
1503
1870
|
parts.join(" — ")
|
|
1504
1871
|
end
|
|
1505
1872
|
private_class_method :offer_line
|
|
@@ -1507,6 +1874,7 @@ module Portage
|
|
|
1507
1874
|
def self.format_compare(report)
|
|
1508
1875
|
lines = [report[:message].to_s]
|
|
1509
1876
|
report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
|
|
1877
|
+
lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
|
|
1510
1878
|
lines.join("\n")
|
|
1511
1879
|
end
|
|
1512
1880
|
private_class_method :format_compare
|
|
@@ -1514,6 +1882,7 @@ module Portage
|
|
|
1514
1882
|
def self.compare_offer_line(offer)
|
|
1515
1883
|
parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
|
|
1516
1884
|
parts << "browse only" unless offer[:checkout]
|
|
1885
|
+
parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
|
|
1517
1886
|
parts.join(" — ")
|
|
1518
1887
|
end
|
|
1519
1888
|
private_class_method :compare_offer_line
|
|
@@ -1525,9 +1894,7 @@ module Portage
|
|
|
1525
1894
|
end
|
|
1526
1895
|
private_class_method :format_price
|
|
1527
1896
|
|
|
1528
|
-
def self.format_amount(amount, currency)
|
|
1529
|
-
"#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
|
|
1530
|
-
end
|
|
1897
|
+
def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
|
|
1531
1898
|
private_class_method :format_amount
|
|
1532
1899
|
end
|
|
1533
1900
|
end
|