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.
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
- # Recorded as a search, not a purchase — compare never checks out. The
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 :run_buy
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. Piped/CI runs list the offers and stop.
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
- output = json ? JSON.pretty_generate(report) : format_find(report)
283
- puts output
284
- return nil unless $stdin.tty? && report[:offers].any?
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[:offers])
403
+ prompt_for_offer(prompt, report)
287
404
  end
288
405
  private_class_method :pick_offer
289
406
 
290
- def self.prompt_for_offer(offers)
291
- print "\nPick 1-#{offers.length} to buy (Enter to quit): "
292
- choice = $stdin.gets.to_s.strip
293
- return nil unless choice.match?(/\A\d+\z/)
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
- options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
301
- handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
302
- options[:product_id] ||= product_id
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[:checkout] || report[:browse] ? 0 : 1
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: "invalid_option", browse: false, checkout: false, message: message)
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 || !buy[:query].strip.empty?
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.to_h
790
- puts json ? JSON.pretty_generate(policy) : format_policy(policy)
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
- return "(no policy configured — every check passes)" if policy.empty?
797
-
798
- JSON.pretty_generate(policy)
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.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
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.to_h)
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