portage-cli 0.6.4 → 0.7.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
@@ -23,7 +23,8 @@ module Portage
23
23
  USAGE = <<~USAGE.freeze
24
24
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
25
25
  [--product-id ID] [--yes] [--dry-run]
26
- [--auto-open|--no-auto-open] [--notify-webhook URL] [--json]
26
+ [--auto-open|--no-auto-open] [--notify-webhook URL]
27
+ [--decision-backend jev|laya] [--min-confidence N] [--json]
27
28
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
28
29
  portage find --query "..." [--max-price N] [--limit N] [--json]
29
30
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -43,13 +44,16 @@ module Portage
43
44
  [--velocity-count N --velocity-window-seconds N]
44
45
  [--allow HOST ...] [--clear-allowlist]
45
46
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
47
+ portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
48
+ portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
46
49
  portage generate adapter NAME [--dir DIR]
47
50
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
48
51
  USAGE
49
52
 
50
53
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
51
54
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
52
- "doctor" => :run_doctor, "generate" => :run_generate }.freeze
55
+ "doctor" => :run_doctor, "configure" => :run_doctor, "setup" => :run_doctor,
56
+ "generate" => :run_generate }.freeze
53
57
 
54
58
  # @param argv [Array<String>]
55
59
  # @return [Integer] process exit code
@@ -69,13 +73,18 @@ module Portage
69
73
 
70
74
  json = options.delete(:json)
71
75
  report = Find.new(**options).call
72
- History.new.record_search(query: report[:query], offer_count: report[:offers].length,
73
- message: report[:message])
76
+ record_find(report)
74
77
  puts json ? JSON.pretty_generate(report) : format_find(report)
75
78
  report[:offers].any? ? 0 : 1
76
79
  end
77
80
  private_class_method :run_find
78
81
 
82
+ def self.record_find(report)
83
+ History.new.record_search(query: report[:query], offer_count: report[:offers].length,
84
+ message: report[:message])
85
+ end
86
+ private_class_method :record_find
87
+
79
88
  def self.parse_find_options(argv)
80
89
  opts = {}
81
90
  find_option_parser(opts).parse!(argv)
@@ -155,6 +164,8 @@ module Portage
155
164
  return 1 unless parsed
156
165
 
157
166
  url = parsed[:buy][:url] || parsed[:store]
167
+ parsed[:confidence_check] = confidence_check(parsed, url)
168
+ return 1 unless parsed[:confidence_check]
158
169
  return execute_buy(parsed, url) if url
159
170
 
160
171
  buy_from_search(parsed)
@@ -168,6 +179,7 @@ module Portage
168
179
  # interactive pick has to name it. Piped/CI runs list the offers and stop.
169
180
  def self.buy_from_search(parsed)
170
181
  report = Find.new(**parsed[:find]).call
182
+ record_find(report)
171
183
  offer = pick_offer(report, parsed[:json])
172
184
  return report[:offers].any? ? 0 : 1 unless offer
173
185
 
@@ -194,36 +206,84 @@ module Portage
194
206
  private_class_method :prompt_for_offer
195
207
 
196
208
  def self.execute_buy(parsed, url, product_id: nil)
197
- options = parsed[:buy].merge(url: url)
209
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
198
210
  options[:product_id] ||= product_id
199
211
  report = Buy.new(**options).call
200
- record_purchase(report, options[:query]) if report[:checkout]
212
+ record_buy(report, options[:query])
201
213
  puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
202
214
  report[:checkout] || report[:browse] ? 0 : 1
203
215
  end
204
216
  private_class_method :execute_buy
205
217
 
206
- # Only checkout attempts land here — a browse-only report never reached a
207
- # checkout, so it belongs to search history, not purchase history.
208
- def self.record_purchase(report, query)
209
- History.new.record_purchase(
210
- url: report[:url], query: query, checkout: report[:checkout],
211
- checkout_status: report[:checkout_status], message: report[:message],
212
- products: report[:products].map { |p| product_line(p) }
218
+ # Built before the buy starts (and before the search, when there's no
219
+ # URL), so a bad --min-confidence, or a bad PORTAGE_MIN_CONFIDENCE with
220
+ # a backend enabled, stops the run up front rather than after a
221
+ # checkout already exists.
222
+ def self.confidence_check(parsed, url)
223
+ ConfidenceCheck.new(**parsed[:confidence])
224
+ rescue ArgumentError => e
225
+ invalid_buy_option(e.message, url: url, json: parsed[:json])
226
+ end
227
+ private_class_method :confidence_check
228
+
229
+ # A buy refused before it started. Under --json that's a report like
230
+ # any other, with outcome `invalid_option`, so an agent loop reading
231
+ # stdout gets JSON rather than nothing and a line on stderr.
232
+ # @return [nil]
233
+ def self.invalid_buy_option(message, url:, json:)
234
+ unless json
235
+ warn message
236
+ return nil
237
+ end
238
+
239
+ puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
240
+ outcome: "invalid_option", browse: false, checkout: false, message: message)
241
+ nil
242
+ end
243
+ private_class_method :invalid_buy_option
244
+
245
+ # A buy that created a checkout is a purchase entry, whatever its
246
+ # outcome. One that never got that far (no match, browse-only, dead end,
247
+ # a store or adapter error) is a search at that store, so "what did I
248
+ # already buy" never lists a checkout that doesn't exist.
249
+ def self.record_buy(report, query)
250
+ history = History.new
251
+ unless report[:checkout_id]
252
+ return history.record_search(query: query, url: report[:url], offer_count: report[:products].length,
253
+ message: report[:message])
254
+ end
255
+
256
+ history.record_purchase(
257
+ url: report[:url], query: query, outcome: report[:outcome], source: report[:source],
258
+ checkout_id: report[:checkout_id], checkout_status: report[:checkout_status],
259
+ checkout_url: report[:checkout_url], total: report_total(report), currency: report[:currency],
260
+ items: Array(report[:items]).map { |item| item.transform_keys(&:to_s) }, message: report[:message]
213
261
  )
214
262
  end
215
- private_class_method :record_purchase
263
+ private_class_method :record_buy
264
+
265
+ def self.report_total(report)
266
+ Portage::Ucp::Support::Totals.amount(report[:totals])
267
+ end
268
+ private_class_method :report_total
216
269
 
270
+ # A flag OptionParser can't read (`--min-confidence high`, `--qty two`,
271
+ # an unknown flag) is refused the same way as an out-of-range
272
+ # threshold. `--json` is looked for up front, since parsing stops at
273
+ # the bad flag and may never reach it.
217
274
  def self.parse_buy_options(argv)
218
275
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
219
276
  buy = { url: url, qty: 1, yes: false, dry_run: false }
220
- parsed = { buy: buy, find: {} }
277
+ parsed = { buy: buy, find: {}, confidence: {} }
278
+ json = argv.include?("--json")
221
279
  buy_option_parser(buy, parsed).parse!(argv)
222
280
  buy[:query] ||= ""
223
281
  return parsed if url || !buy[:query].strip.empty?
224
282
 
225
283
  warn USAGE
226
284
  nil
285
+ rescue OptionParser::ParseError => e
286
+ invalid_buy_option(e.message, url: url, json: json)
227
287
  end
228
288
  private_class_method :parse_buy_options
229
289
 
@@ -238,10 +298,19 @@ module Portage
238
298
  parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
239
299
  parser.on("--json") { parsed[:json] = true }
240
300
  add_search_options(parser, buy, parsed)
301
+ add_confidence_options(parser, parsed[:confidence])
241
302
  end
242
303
  end
243
304
  private_class_method :buy_option_parser
244
305
 
306
+ # The opt-in confidence gate in front of a `--yes` completion (see
307
+ # ConfidenceCheck) — both default to their PORTAGE_* env vars.
308
+ def self.add_confidence_options(parser, confidence)
309
+ parser.on("--decision-backend NAME") { |v| confidence[:backend] = v }
310
+ parser.on("--min-confidence N", Float) { |v| confidence[:threshold] = v }
311
+ end
312
+ private_class_method :add_confidence_options
313
+
245
314
  # `--query` feeds both halves: it's the store search when there's no URL
246
315
  # and the catalog search once a store is settled, so it's registered once
247
316
  # here rather than twice on the same parser.
@@ -310,13 +379,30 @@ module Portage
310
379
  end
311
380
  private_class_method :format_history
312
381
 
382
+ # `outcome` first, since it's what the entry is for. Entries recorded
383
+ # before `outcome` existed fall back to their checkout_status/message.
313
384
  def self.history_purchase_line(entry)
314
- "#{Time.at(entry['at'])} — #{entry['url']} (#{entry['query']}) — #{entry['checkout_status'] || entry['message']}"
385
+ items = Array(entry["items"]).map { |item| item_label(item) }.join(", ")
386
+ [
387
+ "#{Time.at(entry['at'])} — #{entry['outcome'] || entry['checkout_status'] || entry['message']}",
388
+ "#{entry['url']} (#{entry['query']})", (items unless items.empty?),
389
+ (format_amount(entry["total"], entry["currency"]) if entry["total"]),
390
+ (entry["checkout_url"] unless entry["outcome"] == "purchased")
391
+ ].compact.join(" — ")
315
392
  end
316
393
  private_class_method :history_purchase_line
317
394
 
395
+ # Takes a report's symbol-keyed item or a history entry's string-keyed
396
+ # one.
397
+ def self.item_label(item)
398
+ item = item.transform_keys(&:to_s)
399
+ "#{item['title'] || item['id']} x#{item['quantity']}"
400
+ end
401
+ private_class_method :item_label
402
+
318
403
  def self.history_search_line(entry)
319
- "#{Time.at(entry['at'])} — \"#{entry['query']}\" — #{entry['offer_count']} offer(s)"
404
+ where = entry["url"] ? " at #{entry['url']}" : ""
405
+ "#{Time.at(entry['at'])} — \"#{entry['query']}\"#{where} — #{entry['offer_count']} result(s)"
320
406
  end
321
407
  private_class_method :history_search_line
322
408
 
@@ -598,21 +684,44 @@ module Portage
598
684
  puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
599
685
  puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
600
686
  "and off the machine that serves the public profile"
687
+ puts "Next: commit #{result[:profile_path]}, then, once it's on main, run " \
688
+ "`bundle exec rake agent_profile:purge` from the repo root — see docs/agent-profile.md."
601
689
  0
602
690
  end
603
691
  private_class_method :run_generate_agent_profile
604
692
 
605
693
  # --- output ---
606
694
 
695
+ # The `[outcome]` tag leads so a caller reading text, not --json, has the
696
+ # same value to branch on that the JSON report carries.
607
697
  def self.format_report(report)
608
- lines = ["#{report[:message]} (source: #{report[:source]})"]
698
+ lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
609
699
  report[:products].each { |p| lines << " - #{product_line(p)}" }
700
+ lines.concat(format_checkout(report))
610
701
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
611
702
  lines.concat(format_handoff(report[:handoff])) if report[:handoff]
703
+ lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
612
704
  lines.join("\n")
613
705
  end
614
706
  private_class_method :format_report
615
707
 
708
+ # What the checkout holds, as opposed to the search results above it,
709
+ # and where it differs from the request.
710
+ def self.format_checkout(report)
711
+ lines = Array(report[:items]).map { |item| " in checkout: #{item_label(item)}" }
712
+ total = report_total(report)
713
+ lines << " total: #{format_amount(total, report[:currency])}" if total
714
+ lines + Array(report[:warnings]).map { |w| " warning: #{w}" }
715
+ end
716
+ private_class_method :format_checkout
717
+
718
+ def self.format_decisions(decisions)
719
+ decisions.map do |name, verdict|
720
+ " decision #{name}: #{verdict.compact.map { |key, value| "#{key}=#{value}" }.join(' ')}"
721
+ end
722
+ end
723
+ private_class_method :format_decisions
724
+
616
725
  def self.format_handoff(handoff)
617
726
  lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
618
727
  lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
@@ -656,8 +765,13 @@ module Portage
656
765
  def self.format_price(offer)
657
766
  return "price n/a" unless offer[:amount]
658
767
 
659
- "#{format('%.2f', offer[:amount] / 100.0)}#{" #{offer[:currency]}" if offer[:currency]}"
768
+ format_amount(offer[:amount], offer[:currency])
660
769
  end
661
770
  private_class_method :format_price
771
+
772
+ def self.format_amount(amount, currency)
773
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
774
+ end
775
+ private_class_method :format_amount
662
776
  end
663
777
  end
metadata CHANGED
@@ -1,14 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.4
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
8
  autorequire:
9
9
  bindir: exe
10
10
  cert_chain: []
11
- date: 2026-09-22 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: portage-ucp
@@ -16,14 +16,14 @@ dependencies:
16
16
  requirements:
17
17
  - - "~>"
18
18
  - !ruby/object:Gem::Version
19
- version: '0.8'
19
+ version: '0.9'
20
20
  type: :runtime
21
21
  prerelease: false
22
22
  version_requirements: !ruby/object:Gem::Requirement
23
23
  requirements:
24
24
  - - "~>"
25
25
  - !ruby/object:Gem::Version
26
- version: '0.8'
26
+ version: '0.9'
27
27
  - !ruby/object:Gem::Dependency
28
28
  name: portage-ucp-client
29
29
  requirement: !ruby/object:Gem::Requirement
@@ -31,6 +31,9 @@ dependencies:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
33
  version: '0.6'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 0.6.2
34
37
  type: :runtime
35
38
  prerelease: false
36
39
  version_requirements: !ruby/object:Gem::Requirement
@@ -38,6 +41,9 @@ dependencies:
38
41
  - - "~>"
39
42
  - !ruby/object:Gem::Version
40
43
  version: '0.6'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.6.2
41
47
  - !ruby/object:Gem::Dependency
42
48
  name: portage-ucp-journal
43
49
  requirement: !ruby/object:Gem::Requirement
@@ -119,7 +125,9 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
119
125
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
120
126
  portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
121
127
  read-only view of local purchase/transaction/order state); no single adapter gem
122
- is a hard dependency.'
128
+ is a hard dependency. Ranking, escalation and the spend-policy check use portage-ucp
129
+ core''s rules; portage-ucp-decision is optional and only the opt-in confidence gate
130
+ needs it.'
123
131
  email:
124
132
  executables:
125
133
  - portage
@@ -138,8 +146,10 @@ files:
138
146
  - lib/portage/cli/catalog_products.rb
139
147
  - lib/portage/cli/checkout_handoff.rb
140
148
  - lib/portage/cli/compare.rb
149
+ - lib/portage/cli/confidence_check.rb
141
150
  - lib/portage/cli/config.rb
142
151
  - lib/portage/cli/console.rb
152
+ - lib/portage/cli/decisions.rb
143
153
  - lib/portage/cli/doctor.rb
144
154
  - lib/portage/cli/find.rb
145
155
  - lib/portage/cli/generate/adapter.rb
@@ -152,6 +162,7 @@ files:
152
162
  - lib/portage/cli/payment_methods/secret_service_backend.rb
153
163
  - lib/portage/cli/probe_cache.rb
154
164
  - lib/portage/cli/search_backends.rb
165
+ - lib/portage/cli/setting.rb
155
166
  - lib/portage/cli/shipping_profile.rb
156
167
  - lib/portage/cli/version.rb
157
168
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli