portage-cli 0.8.0 → 0.10.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
data/lib/portage/cli.rb CHANGED
@@ -15,7 +15,16 @@ require_relative "cli/browser_profile"
15
15
  require_relative "cli/buy"
16
16
  require_relative "cli/find"
17
17
  require_relative "cli/compare"
18
+ require_relative "cli/check"
18
19
  require_relative "cli/history"
20
+ require_relative "cli/quotes"
21
+ require_relative "cli/money"
22
+ require_relative "cli/human_prompt"
23
+ require_relative "cli/product_page"
24
+ require_relative "cli/approval_policy"
25
+ require_relative "cli/offer_choice"
26
+ require_relative "cli/pick"
27
+ require_relative "cli/approve"
19
28
  require_relative "cli/payment_methods"
20
29
  require_relative "cli/proxy_settings"
21
30
  require_relative "cli/handoff_only"
@@ -42,10 +51,16 @@ module Portage
42
51
  [--handoff-target default|print|profile|agent:NAME]
43
52
  [--decision-backend jev|laya] [--min-confidence N] [--json]
44
53
  [--wait [--wait-timeout DURATION|off]]
54
+ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
55
+ portage buy --quote QUOTE_ID --yes [--json] ...
45
56
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
46
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
47
58
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
48
59
  [--max-price N] [--json]
60
+ portage check <url> [--json]
61
+ portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
62
+ [--choose REF | --compare REF | --view REF]
63
+ portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
49
64
  portage history [list] [--purchases|--searches] [--limit N] [--json]
50
65
  portage history clear [--purchases|--searches]
51
66
  portage payment list [--json]
@@ -60,6 +75,7 @@ module Portage
60
75
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
61
76
  [--velocity-count N --velocity-window-seconds N]
62
77
  [--allow HOST ...] [--clear-allowlist]
78
+ [--require-approval person|any|off] (lowering asks at a terminal)
63
79
  portage orders reconcile [--checkout ID] [--json]
64
80
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
65
81
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
@@ -79,13 +95,14 @@ module Portage
79
95
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
80
96
  portage --version
81
97
 
82
- proxy flags (buy/find/compare/doctor/payment enroll):
98
+ proxy flags (buy/find/compare/check/doctor/payment enroll):
83
99
  [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
84
100
  [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
85
101
  [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
86
102
  USAGE
87
103
 
88
- COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
104
+ COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare, "check" => :run_check,
105
+ "pick" => :run_pick, "approve" => :run_approve,
89
106
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
90
107
  "orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
91
108
  "doctor" => :run_doctor,
@@ -142,18 +159,26 @@ module Portage
142
159
  return 1 unless apply_proxy_settings(options.delete(:proxy))
143
160
 
144
161
  report = Find.new(**options).call
145
- record_find(report)
162
+ report = with_search_id(report, record_find(report))
146
163
  puts json ? JSON.pretty_generate(report) : format_find(report)
147
164
  report[:offers].any? ? 0 : 1
148
165
  end
149
166
  private_class_method :run_find
150
167
 
168
+ # @return [Hash, nil] the saved search entry.
151
169
  def self.record_find(report)
152
170
  History.new.record_search(query: report[:query], offer_count: report[:offers].length,
153
- message: report[:message])
171
+ message: report[:message], offers: report[:offers])
154
172
  end
155
173
  private_class_method :record_find
156
174
 
175
+ # docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
176
+ # for `portage pick --search`. Additive; absent when nothing was saved.
177
+ def self.with_search_id(report, entry)
178
+ entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
179
+ end
180
+ private_class_method :with_search_id
181
+
157
182
  def self.parse_find_options(argv)
158
183
  opts = {}
159
184
  find_option_parser(opts).parse!(argv)
@@ -184,6 +209,53 @@ module Portage
184
209
  def self.to_minor_units(major) = (major * 100).round
185
210
  private_class_method :to_minor_units
186
211
 
212
+ # --- check ---
213
+
214
+ # `portage check <url>` — can Portage buy from this store, and how?
215
+ # Exit 0 for `automated`/`webmcp`, 1 for anything else (and for a bad
216
+ # or missing URL, with the usage on stderr).
217
+ def self.run_check(argv)
218
+ opts = parse_check_options(argv)
219
+ return 1 unless opts
220
+ return 1 unless apply_proxy_settings(opts[:proxy])
221
+
222
+ report = Check.new(opts[:url]).call
223
+ puts opts[:json] ? JSON.pretty_generate(report) : format_check(report)
224
+ Check::USABLE_VERDICTS.include?(report[:verdict]) ? 0 : 1
225
+ end
226
+ private_class_method :run_check
227
+
228
+ def self.parse_check_options(argv)
229
+ opts = { proxy: {} }
230
+ OptionParser.new do |parser|
231
+ parser.on("--json") { opts[:json] = true }
232
+ ProxySettings.add_options(parser, opts[:proxy])
233
+ end.parse!(argv)
234
+ opts[:url] = argv.shift.to_s.strip
235
+ return opts if !opts[:url].empty? && argv.empty? && valid_check_url?(opts[:url])
236
+
237
+ warn USAGE
238
+ nil
239
+ end
240
+ private_class_method :parse_check_options
241
+
242
+ def self.valid_check_url?(url)
243
+ uri = URI.parse(url =~ %r{\Ahttps?://}i ? url : "https://#{url}")
244
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
245
+ rescue URI::InvalidURIError
246
+ false
247
+ end
248
+ private_class_method :valid_check_url?
249
+
250
+ def self.format_check(report)
251
+ lines = ["#{report[:url]}: #{report[:verdict]}"]
252
+ lines << " platform: #{report[:platform]}" if report[:platform]
253
+ lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
254
+ lines << " next: #{report[:next_step]}"
255
+ lines.join("\n")
256
+ end
257
+ private_class_method :format_check
258
+
187
259
  # --- compare ---
188
260
 
189
261
  def self.run_compare(argv)
@@ -195,16 +267,26 @@ module Portage
195
267
  return 1 unless apply_proxy_settings(options.delete(:proxy))
196
268
 
197
269
  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])
270
+ report = with_search_id(report, record_compare(url, options[:origin_product_id], report))
203
271
  puts json ? JSON.pretty_generate(report) : format_compare(report)
204
272
  report[:offers].any? ? 0 : 1
205
273
  end
206
274
  private_class_method :run_compare
207
275
 
276
+ # Recorded as a search, not a purchase — compare never checks out. The
277
+ # query string names the compare so `portage history list` doesn't
278
+ # read it as a plain text search for the origin product's own title.
279
+ # Its offers are kept with their refs, like `find`'s
280
+ # (docs/plans/human-pick-and-approve.md Phase 2), each carrying the
281
+ # catalog query compare searched with (the origin product's title), so
282
+ # `buy --offer REF` searches for the product, not for "compare: ...".
283
+ def self.record_compare(url, product_id, report)
284
+ offers = report[:offers].map { |offer| offer.merge(query: report[:query]) }
285
+ History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
286
+ message: report[:message], offers: offers)
287
+ end
288
+ private_class_method :record_compare
289
+
208
290
  def self.parse_compare_options(argv)
209
291
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
210
292
  opts = { identity: [] }
@@ -245,11 +327,21 @@ module Portage
245
327
 
246
328
  parsed[:handoff_target] = handoff_target(parsed, url)
247
329
  return 1 unless parsed[:handoff_target]
330
+
331
+ dispatch_buy(parsed, url)
332
+ end
333
+ private_class_method :run_buy
334
+
335
+ # A saved quote or offer names its own store; otherwise a url does, and
336
+ # with neither the search picks one.
337
+ def self.dispatch_buy(parsed, url)
338
+ return buy_from_quote(parsed) if parsed[:quote]
339
+ return buy_from_offer(parsed) if parsed[:offer]
248
340
  return execute_buy(parsed, url) if url
249
341
 
250
342
  buy_from_search(parsed)
251
343
  end
252
- private_class_method :run_buy
344
+ private_class_method :dispatch_buy
253
345
 
254
346
  # Built and validated up front, same posture as #confidence_check — an
255
347
  # unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
@@ -267,48 +359,205 @@ module Portage
267
359
  # caller picks. `--yes` alone deliberately isn't enough to get here —
268
360
  # without a URL the merchant would have been chosen by a search ranker
269
361
  # 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.
362
+ # interactive pick has to name it. Runs with no terminal (or under
363
+ # --json) list the offers and stop.
271
364
  def self.buy_from_search(parsed)
272
365
  report = Find.new(**parsed[:find]).call
273
- record_find(report)
366
+ report = with_search_id(report, record_find(report))
274
367
  offer = pick_offer(report, parsed[:json])
275
368
  return report[:offers].any? ? 0 : 1 unless offer
276
369
 
370
+ parsed[:offer_ref] = offer[:offer_ref]
371
+ parsed[:page] = { title: offer[:title], url: offer[:url] }
277
372
  execute_buy(parsed, offer[:store], product_id: offer[:product_id])
278
373
  end
279
374
  private_class_method :buy_from_search
280
375
 
376
+ # `--offer REF`: the store, product and query come from the saved
377
+ # `find` that produced the ref, as if they'd been passed as flags.
378
+ def self.buy_from_offer(parsed)
379
+ offer = History.new.offer(parsed[:offer])
380
+ unless offer
381
+ invalid_buy_option("No saved offer #{parsed[:offer]} — run `portage find` again.",
382
+ url: nil, json: parsed[:json], outcome: "offer_not_found")
383
+ return 1
384
+ end
385
+
386
+ parsed[:offer_ref] = parsed[:offer]
387
+ parsed[:page] = { title: offer["title"], url: offer["url"] }
388
+ parsed[:buy][:query] = offer["query"].to_s
389
+ execute_buy(parsed, offer["store"], product_id: offer["product_id"])
390
+ end
391
+ private_class_method :buy_from_offer
392
+
393
+ # `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
394
+ # quoted total as a cap and refuses (`quote_changed`) if the real
395
+ # checkout costs more, so the person's approval always covers the total
396
+ # that gets charged. The quote is spent by #settle_quote, once the run
397
+ # purchases or hands off.
398
+ #
399
+ # docs/plans/human-pick-and-approve.md Phase 2: a real run (`--yes`, not
400
+ # `--dry-run`) of a quote that isn't approved enough for
401
+ # `--require-approval` is refused with `needs_approval` before Buy
402
+ # runs, so it never charges and never hands off.
403
+ def self.buy_from_quote(parsed)
404
+ quote = usable_quote(parsed)
405
+ return 1 unless quote
406
+
407
+ level = ApprovalPolicy.level
408
+ if real_run?(parsed) && !ApprovalPolicy.satisfied?(quote, level)
409
+ return refuse_unapproved_quote(parsed, quote, level)
410
+ end
411
+
412
+ parsed[:quote_record] = quote
413
+ buy = parsed[:buy]
414
+ buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
415
+ buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
416
+ execute_buy(parsed, quote["store"])
417
+ end
418
+ private_class_method :buy_from_quote
419
+
420
+ def self.usable_quote(parsed)
421
+ quote = Quotes.new.find(parsed[:quote])
422
+ unless quote
423
+ return invalid_buy_option("No saved quote #{parsed[:quote]} — run `portage buy ... --dry-run --json` " \
424
+ "for a new one.", url: nil, json: parsed[:json], outcome: "quote_not_found")
425
+ end
426
+ return quote unless quote["used_at"]
427
+
428
+ invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
429
+ url: quote["store"], json: parsed[:json], outcome: "quote_used")
430
+ end
431
+ private_class_method :usable_quote
432
+
433
+ def self.refuse_unapproved_quote(parsed, quote, level)
434
+ summary = Approve.summary(quote)
435
+ report = { url: quote["store"], checkout_url: nil, products: [], warnings: [], source: "none", browse: false,
436
+ checkout: false }.merge(Approve.needs_approval(summary, message: approval_message(summary, level)))
437
+ print_buy_report(report, nil, parsed[:json])
438
+ buy_exit_code(report)
439
+ end
440
+ private_class_method :refuse_unapproved_quote
441
+
442
+ # With a terminal (and no --json) the person picks there, through the
443
+ # same HumanPrompt numbered pick `portage pick` uses; otherwise the
444
+ # offers are printed and nothing is bought.
281
445
  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?
446
+ prompt = HumanPrompt.new(json: json)
447
+ unless prompt.tty? && report[:offers].any?
448
+ puts json ? JSON.pretty_generate(report) : format_find(report)
449
+ return nil
450
+ end
285
451
 
286
- prompt_for_offer(report[:offers])
452
+ prompt_for_offer(prompt, report)
287
453
  end
288
454
  private_class_method :pick_offer
289
455
 
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)
456
+ def self.prompt_for_offer(prompt, report)
457
+ prompt.say(report[:message].to_s)
458
+ choices = report[:offers].map { |offer| OfferChoice.for(offer) }
459
+ index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
460
+ index && report[:offers][index]
296
461
  end
297
462
  private_class_method :prompt_for_offer
298
463
 
464
+ # docs/plans/human-pick-and-approve.md Phase 2: under
465
+ # `--require-approval any|person` a real `--yes` run with no approved
466
+ # `--quote` is run as a dry run instead — never charged, never handed
467
+ # off — which prices it and saves a quote, and the report becomes
468
+ # `needs_approval` naming that quote and the next steps. Done here, not
469
+ # in Buy, so Buy's library callers aren't governed by the CLI policy.
299
470
  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?
471
+ gated = approval_gate?(parsed)
472
+ parsed[:buy] = parsed[:buy].merge(yes: false, dry_run: true) if gated
473
+ options = buy_options(parsed, url, product_id: product_id)
304
474
  report = Buy.new(**options).call
305
475
  record_buy(report, options[:query])
476
+ report = settle_quote(report, parsed, options)
477
+ report = needs_approval_report(report) if gated
306
478
  result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
307
479
  print_buy_report(report, result, parsed[:json])
308
- report[:checkout] || report[:browse] ? 0 : 1
480
+ buy_exit_code(report)
309
481
  end
310
482
  private_class_method :execute_buy
311
483
 
484
+ def self.buy_options(parsed, url, product_id: nil)
485
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
486
+ handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
487
+ options[:product_id] ||= product_id
488
+ options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
489
+ options
490
+ end
491
+ private_class_method :buy_options
492
+
493
+ # `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
494
+ # did what it could and is waiting on the person.
495
+ def self.buy_exit_code(report)
496
+ report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
497
+ end
498
+ private_class_method :buy_exit_code
499
+
500
+ def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
501
+ private_class_method :real_run?
502
+
503
+ # An approved quote already passed ApprovalPolicy in #buy_from_quote.
504
+ def self.approval_gate?(parsed)
505
+ real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
506
+ end
507
+ private_class_method :approval_gate?
508
+
509
+ # A gated run that got as far as a priced checkout saved a quote; one
510
+ # that didn't (no match, a dead end) is reported as it is — nothing
511
+ # was bought either way.
512
+ def self.needs_approval_report(report)
513
+ quote = report[:quote_id] && Quotes.new.find(report[:quote_id])
514
+ return report unless quote
515
+
516
+ summary = Approve.summary(quote)
517
+ report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
518
+ end
519
+ private_class_method :needs_approval_report
520
+
521
+ def self.approval_message(summary, level)
522
+ id = summary[:quote_id]
523
+ held = if summary[:approved_by]
524
+ "Quote #{id} was approved by #{summary[:approved_by]}, which require_approval #{level} doesn't accept."
525
+ else
526
+ "Quote #{id} (#{Approve.describe(summary)}) needs approval first (require_approval: #{level})."
527
+ end
528
+ how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
529
+ "Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
530
+ end
531
+ private_class_method :approval_message
532
+
533
+ # A dry run saves a quote and reports its `quote_id`. A run of a saved
534
+ # quote spends it once it purchases or hands off; any other outcome
535
+ # (needs_confirmation, a dry run, an error) leaves it usable.
536
+ def self.settle_quote(report, parsed, options)
537
+ quote = parsed[:quote_record]
538
+ if quote
539
+ Quotes.new.consume(quote["quote_id"]) if report[:outcome] == "purchased" || report[:handoff]
540
+ return report[:outcome] == "quote_changed" ? report.merge(quote_id: quote["quote_id"]) : report
541
+ end
542
+ return report unless report[:outcome] == "dry_run"
543
+
544
+ saved = Quotes.new.create(offer_ref: parsed[:offer_ref], store: report[:url],
545
+ product_id: options[:product_id], query: options[:query], qty: options[:qty],
546
+ total: report_total(report), currency: report[:currency],
547
+ **quote_page(report, parsed))
548
+ saved ? report.merge(quote_id: saved["quote_id"]) : report
549
+ end
550
+ private_class_method :settle_quote
551
+
552
+ # What `portage approve` shows (docs/plans/human-pick-and-approve.md
553
+ # Phase 2): the title of what's in the checkout, else the picked
554
+ # offer's, and the offer's product page when the buy came from one.
555
+ def self.quote_page(report, parsed)
556
+ page = parsed[:page] || {}
557
+ { title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
558
+ end
559
+ private_class_method :quote_page
560
+
312
561
  # docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
313
562
  # profile` gives `portage buy` a browser of its own — the Portage
314
563
  # profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
@@ -458,14 +707,14 @@ module Portage
458
707
  # any other, with outcome `invalid_option`, so an agent loop reading
459
708
  # stdout gets JSON rather than nothing and a line on stderr.
460
709
  # @return [nil]
461
- def self.invalid_buy_option(message, url:, json:)
710
+ def self.invalid_buy_option(message, url:, json:, outcome: "invalid_option")
462
711
  unless json
463
712
  warn message
464
713
  return nil
465
714
  end
466
715
 
467
716
  puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
468
- outcome: "invalid_option", browse: false, checkout: false, message: message)
717
+ outcome: outcome, browse: false, checkout: false, message: message)
469
718
  nil
470
719
  end
471
720
  private_class_method :invalid_buy_option
@@ -510,7 +759,7 @@ module Portage
510
759
  parser.parse!(argv)
511
760
  url = reinterpret_bare_query(url, buy, parsed)
512
761
  buy[:query] ||= ""
513
- return parsed if url || !buy[:query].strip.empty?
762
+ return parsed if buy_target?(url, buy, parsed)
514
763
 
515
764
  warn USAGE
516
765
  nil
@@ -519,6 +768,11 @@ module Portage
519
768
  end
520
769
  private_class_method :parse_buy_options
521
770
 
771
+ def self.buy_target?(url, buy, parsed)
772
+ url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
773
+ end
774
+ private_class_method :buy_target?
775
+
522
776
  # Bare arg is normally the store URL (`portage buy <url> --query "..."`),
523
777
  # but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
524
778
  # a URL/domain — means the same thing as `portage buy --query "coffee"`:
@@ -583,6 +837,8 @@ module Portage
583
837
  # and the catalog search once a store is settled, so it's registered once
584
838
  # here rather than twice on the same parser.
585
839
  def self.add_search_options(parser, buy, parsed)
840
+ parser.on("--offer REF") { |v| parsed[:offer] = v }
841
+ parser.on("--quote QUOTE_ID") { |v| parsed[:quote] = v }
586
842
  parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
587
843
  parser.on("--store URL") { |v| parsed[:store] = v }
588
844
  parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
@@ -590,6 +846,94 @@ module Portage
590
846
  end
591
847
  private_class_method :add_search_options
592
848
 
849
+ # --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
850
+
851
+ # Outcomes a pick/approve run exits 0 on: an answer, a page shown, or a
852
+ # question handed to the agent. Everything else (cancelled, not found,
853
+ # used, refused, no terminal) exits 1.
854
+ PROMPT_OK_OUTCOMES = %w[picked approved viewed needs_pick needs_approval].freeze
855
+
856
+ def self.run_pick(argv)
857
+ opts = parse_prompt_options(argv) do |parser, o|
858
+ parser.on("--search ID") { |v| o[:search] = v }
859
+ parser.on("--choose REF") { |v| o[:choose] = v }
860
+ parser.on("--compare REF") { |v| o[:compare] = v }
861
+ parser.on("--view REF") { |v| o[:view] = v }
862
+ end
863
+ return 1 unless opts
864
+
865
+ pick = Pick.new(prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]), comparer: method(:compare_offer),
866
+ **opts.slice(:search, :choose, :view, :compare))
867
+ print_prompt_result(pick.call, opts[:json])
868
+ end
869
+ private_class_method :run_pick
870
+
871
+ def self.run_approve(argv)
872
+ quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
873
+ opts = parse_prompt_options(argv) do |parser, o|
874
+ parser.on("--relayed-yes") { o[:relayed_yes] = true }
875
+ parser.on("--view") { o[:view] = true }
876
+ end
877
+ return 1 unless opts
878
+ return prompt_usage(opts[:json], "portage approve needs a QUOTE_ID.") unless quote_id
879
+
880
+ approve = Approve.new(quote_id: quote_id, prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]),
881
+ **opts.slice(:relayed_yes, :view))
882
+ print_prompt_result(approve.call, opts[:json])
883
+ end
884
+ private_class_method :run_approve
885
+
886
+ # `--via`/`--json` for both commands, plus whatever the block adds.
887
+ # @return [Hash, nil] nil on a bad flag (already reported).
888
+ def self.parse_prompt_options(argv)
889
+ opts = { via: "auto" }
890
+ json = argv.include?("--json")
891
+ OptionParser.new do |parser|
892
+ parser.on("--via SURFACE", HumanPrompt::VIAS) { |v| opts[:via] = v }
893
+ parser.on("--json") { opts[:json] = true }
894
+ yield parser, opts
895
+ end.parse!(argv)
896
+ opts
897
+ rescue OptionParser::ParseError => e
898
+ prompt_usage(json, e.message)
899
+ nil
900
+ end
901
+ private_class_method :parse_prompt_options
902
+
903
+ def self.prompt_usage(json, message)
904
+ json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
905
+ 1
906
+ end
907
+ private_class_method :prompt_usage
908
+
909
+ # Pick's "Compare an offer across stores": the same Compare run
910
+ # `portage compare` does, from the saved offer's store and product.
911
+ def self.compare_offer(offer)
912
+ return { offers: [], message: "Couldn't compare — see the proxy error above." } unless apply_proxy_settings({})
913
+
914
+ Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
915
+ end
916
+ private_class_method :compare_offer
917
+
918
+ def self.print_prompt_result(result, json)
919
+ puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
920
+ PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
921
+ end
922
+ private_class_method :print_prompt_result
923
+
924
+ def self.format_prompt_result(result)
925
+ lines = ["[#{result[:outcome]}] #{result[:message]}"]
926
+ Array(result[:choices]).each_with_index { |choice, index| lines << " #{index + 1}. #{choice_line(choice)}" }
927
+ lines << " #{approval_line(result[:summary])}" if result[:summary]
928
+ lines.join("\n")
929
+ end
930
+ private_class_method :format_prompt_result
931
+
932
+ def self.choice_line(choice)
933
+ [choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
934
+ end
935
+ private_class_method :choice_line
936
+
593
937
  # --- history ---
594
938
 
595
939
  def self.run_history(argv)
@@ -783,53 +1127,110 @@ module Portage
783
1127
  end
784
1128
  private_class_method :run_policy
785
1129
 
1130
+ # `require_approval` is always shown at its effective value, the
1131
+ # default included, so "what does `--yes` need right now" is never a
1132
+ # guess.
786
1133
  def self.run_policy_show(argv)
787
1134
  json = false
788
1135
  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)
1136
+ policy = Portage::Ucp::Policy.load
1137
+ effective = policy.to_h.merge(ApprovalPolicy::KEY => ApprovalPolicy.level(policy))
1138
+ puts json ? JSON.pretty_generate(effective) : format_policy(policy)
791
1139
  0
792
1140
  end
793
1141
  private_class_method :run_policy_show
794
1142
 
795
1143
  def self.format_policy(policy)
796
- return "(no policy configured — every check passes)" if policy.empty?
797
-
798
- JSON.pretty_generate(policy)
1144
+ spending = policy.to_h.except(ApprovalPolicy::KEY)
1145
+ body = spending.empty? ? "(no policy configured — every spending check passes)" : JSON.pretty_generate(spending)
1146
+ default = " (default)" unless ApprovalPolicy.configured?(policy)
1147
+ "#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
799
1148
  end
800
1149
  private_class_method :format_policy
801
1150
 
802
1151
  def self.parse_policy_set_options(argv)
803
1152
  opts = { allow: [] }
804
1153
  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 }
1154
+ add_policy_cap_options(parser, opts)
809
1155
  parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
810
1156
  parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
811
1157
  parser.on("--allow HOST") { |v| opts[:allow] << v }
812
1158
  parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
1159
+ parser.on("--require-approval LEVEL", ApprovalPolicy::LEVELS) { |v| opts[:require_approval] = v }
813
1160
  end.parse!(argv)
814
1161
  opts
815
1162
  end
816
1163
  private_class_method :parse_policy_set_options
817
1164
 
1165
+ def self.add_policy_cap_options(parser, opts)
1166
+ parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
1167
+ parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
1168
+ parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
1169
+ parser.on("--currency CUR") { |v| opts[:currency] = v }
1170
+ end
1171
+ private_class_method :add_policy_cap_options
1172
+
818
1173
  # Each `--*` group is applied independently and only when its required
819
1174
  # fields are present — `portage policy set --allow shop.example.com`
820
1175
  # touches only the allowlist, leaving caps/velocity untouched, so caps
821
1176
  # and the allowlist can be configured in separate invocations.
1177
+ #
1178
+ # `--require-approval` goes first: a lowering the person doesn't confirm
1179
+ # at the terminal refuses the whole invocation, so nothing else in it
1180
+ # changes either.
822
1181
  def self.run_policy_set(argv)
823
1182
  opts = parse_policy_set_options(argv)
824
1183
  policy = Portage::Ucp::Policy.load
1184
+ return 1 unless require_approval_applied?(policy, opts[:require_approval])
1185
+
825
1186
  set_policy_cap(policy, opts)
826
1187
  set_policy_velocity(policy, opts)
827
1188
  set_policy_allowlist(policy, opts)
828
- puts format_policy(policy.to_h)
1189
+ puts format_policy(policy)
829
1190
  0
1191
+ rescue OptionParser::ParseError => e
1192
+ warn "#{e.message}\n#{USAGE}"
1193
+ 1
830
1194
  end
831
1195
  private_class_method :run_policy_set
832
1196
 
1197
+ # docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
1198
+ # setting the same one) needs nothing; lowering it (person -> any/off,
1199
+ # any -> off) needs a yes typed on the tty, since an agent with a shell
1200
+ # can run `policy set` but can't type on /dev/tty. No terminal, no
1201
+ # change.
1202
+ # @return [Boolean] false when a lowering was refused (nothing changed).
1203
+ def self.require_approval_applied?(policy, level)
1204
+ return true unless level
1205
+
1206
+ current = ApprovalPolicy.level(policy)
1207
+ return false if ApprovalPolicy.lowering?(current, level) && !confirm_lowering(current, level)
1208
+
1209
+ policy.set(ApprovalPolicy::KEY, level)
1210
+ true
1211
+ end
1212
+ private_class_method :require_approval_applied?
1213
+
1214
+ def self.confirm_lowering(current, level)
1215
+ prompt = HumanPrompt.new(via: "tty")
1216
+ return true if prompt.confirm("Lower require_approval from #{current} to #{level}? #{lowering_effect(level)}")
1217
+
1218
+ warn "require_approval left at #{current}."
1219
+ false
1220
+ rescue HumanPrompt::NoTerminal
1221
+ warn "Lowering require_approval (#{current} -> #{level}) needs a yes typed at a terminal, and there's no " \
1222
+ "terminal here — nothing changed. Run it yourself from a terminal."
1223
+ false
1224
+ end
1225
+ private_class_method :confirm_lowering
1226
+
1227
+ def self.lowering_effect(level)
1228
+ return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
1229
+
1230
+ "An agent relaying your yes would then be enough to buy."
1231
+ end
1232
+ private_class_method :lowering_effect
1233
+
833
1234
  def self.set_policy_cap(policy, opts)
834
1235
  if opts[:per_transaction_cap]
835
1236
  policy.set("per_transaction_cap",
@@ -1454,6 +1855,7 @@ module Portage
1454
1855
  lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
1455
1856
  report[:products].each { |p| lines << " - #{product_line(p)}" }
1456
1857
  lines.concat(format_checkout(report))
1858
+ lines.concat(format_quote(report))
1457
1859
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
1458
1860
  lines.concat(format_handoff(report[:handoff])) if report[:handoff]
1459
1861
  lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
@@ -1461,6 +1863,18 @@ module Portage
1461
1863
  end
1462
1864
  private_class_method :format_report
1463
1865
 
1866
+ def self.format_quote(report)
1867
+ lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
1868
+ lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
1869
+ lines
1870
+ end
1871
+ private_class_method :format_quote
1872
+
1873
+ def self.approval_line(summary)
1874
+ [Approve.describe(summary), summary[:url]].compact.join(" — ")
1875
+ end
1876
+ private_class_method :approval_line
1877
+
1464
1878
  # What the checkout holds, as opposed to the search results above it,
1465
1879
  # and where it differs from the request.
1466
1880
  def self.format_checkout(report)
@@ -1493,6 +1907,7 @@ module Portage
1493
1907
  def self.format_find(report)
1494
1908
  lines = [report[:message].to_s]
1495
1909
  report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
1910
+ lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
1496
1911
  lines.join("\n")
1497
1912
  end
1498
1913
  private_class_method :format_find
@@ -1500,6 +1915,7 @@ module Portage
1500
1915
  def self.offer_line(offer)
1501
1916
  parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
1502
1917
  parts << "browse only" unless offer[:checkout]
1918
+ parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
1503
1919
  parts.join(" — ")
1504
1920
  end
1505
1921
  private_class_method :offer_line
@@ -1507,6 +1923,7 @@ module Portage
1507
1923
  def self.format_compare(report)
1508
1924
  lines = [report[:message].to_s]
1509
1925
  report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
1926
+ lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
1510
1927
  lines.join("\n")
1511
1928
  end
1512
1929
  private_class_method :format_compare
@@ -1514,6 +1931,7 @@ module Portage
1514
1931
  def self.compare_offer_line(offer)
1515
1932
  parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
1516
1933
  parts << "browse only" unless offer[:checkout]
1934
+ parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
1517
1935
  parts.join(" — ")
1518
1936
  end
1519
1937
  private_class_method :compare_offer_line
@@ -1525,9 +1943,7 @@ module Portage
1525
1943
  end
1526
1944
  private_class_method :format_price
1527
1945
 
1528
- def self.format_amount(amount, currency)
1529
- "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
1530
- end
1946
+ def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
1531
1947
  private_class_method :format_amount
1532
1948
  end
1533
1949
  end