portage-cli 0.6.0 → 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.
@@ -0,0 +1,44 @@
1
+ module Portage
2
+ module Cli
3
+ # How portage-cli resolves a standing preference, in one place
4
+ # (docs/plans/checkout-handoff-delivery.md open decision #1): a
5
+ # per-invocation override (a flag) beats its PORTAGE_* env var, which
6
+ # beats its ~/.portage/config.json key (Config). CheckoutHandoff,
7
+ # Notifier, Buy and ConfidenceCheck all read their settings through it.
8
+ #
9
+ # nil or a blank string counts as unset at every level, so an exported
10
+ # but empty `PORTAGE_AUTO_OPEN_CHECKOUT=` falls through to config.json
11
+ # rather than silently meaning "off".
12
+ module Setting
13
+ TRUE_VALUES = %w[1 true yes].freeze
14
+
15
+ module_function
16
+
17
+ # @param override [Object, nil] the flag's value, nil when not passed.
18
+ # @param env [String, nil] the env var's name.
19
+ # @param config [Config, nil]
20
+ # @param config_key [String, nil]
21
+ # @return [Object, nil] the value from the first level that's set.
22
+ def resolve(override: nil, env: nil, config: nil, config_key: nil)
23
+ return override if set?(override)
24
+
25
+ from_env = env && ENV.fetch(env, nil)
26
+ return from_env if set?(from_env)
27
+
28
+ from_config = config_key && config&.get(config_key)
29
+ from_config if set?(from_config)
30
+ end
31
+
32
+ # The same precedence, read as yes/no: true, or a string in
33
+ # TRUE_VALUES (any case). Anything else, unset included, is no.
34
+ def flag?(**)
35
+ TRUE_VALUES.include?(resolve(**).to_s.strip.downcase)
36
+ end
37
+
38
+ def set?(value)
39
+ !(value.nil? || (value.is_a?(String) && value.strip.empty?))
40
+ end
41
+ private_class_method :set?
42
+ end
43
+ end
44
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.6.0".freeze
3
+ VERSION = "0.7.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -3,6 +3,7 @@ require "json"
3
3
 
4
4
  require_relative "cli/version"
5
5
  require_relative "cli/shipping_profile"
6
+ require_relative "cli/buyer_context"
6
7
  require_relative "cli/catalog_products"
7
8
  require_relative "cli/buy"
8
9
  require_relative "cli/find"
@@ -21,7 +22,9 @@ module Portage
21
22
  module Cli
22
23
  USAGE = <<~USAGE.freeze
23
24
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
24
- [--product-id ID] [--yes] [--dry-run] [--json]
25
+ [--product-id ID] [--yes] [--dry-run]
26
+ [--auto-open|--no-auto-open] [--notify-webhook URL]
27
+ [--decision-backend jev|laya] [--min-confidence N] [--json]
25
28
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
26
29
  portage find --query "..." [--max-price N] [--limit N] [--json]
27
30
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
@@ -41,13 +44,16 @@ module Portage
41
44
  [--velocity-count N --velocity-window-seconds N]
42
45
  [--allow HOST ...] [--clear-allowlist]
43
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)
44
49
  portage generate adapter NAME [--dir DIR]
45
50
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
46
51
  USAGE
47
52
 
48
53
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
49
54
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
50
- "doctor" => :run_doctor, "generate" => :run_generate }.freeze
55
+ "doctor" => :run_doctor, "configure" => :run_doctor, "setup" => :run_doctor,
56
+ "generate" => :run_generate }.freeze
51
57
 
52
58
  # @param argv [Array<String>]
53
59
  # @return [Integer] process exit code
@@ -67,13 +73,18 @@ module Portage
67
73
 
68
74
  json = options.delete(:json)
69
75
  report = Find.new(**options).call
70
- History.new.record_search(query: report[:query], offer_count: report[:offers].length,
71
- message: report[:message])
76
+ record_find(report)
72
77
  puts json ? JSON.pretty_generate(report) : format_find(report)
73
78
  report[:offers].any? ? 0 : 1
74
79
  end
75
80
  private_class_method :run_find
76
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
+
77
88
  def self.parse_find_options(argv)
78
89
  opts = {}
79
90
  find_option_parser(opts).parse!(argv)
@@ -153,6 +164,8 @@ module Portage
153
164
  return 1 unless parsed
154
165
 
155
166
  url = parsed[:buy][:url] || parsed[:store]
167
+ parsed[:confidence_check] = confidence_check(parsed, url)
168
+ return 1 unless parsed[:confidence_check]
156
169
  return execute_buy(parsed, url) if url
157
170
 
158
171
  buy_from_search(parsed)
@@ -166,6 +179,7 @@ module Portage
166
179
  # interactive pick has to name it. Piped/CI runs list the offers and stop.
167
180
  def self.buy_from_search(parsed)
168
181
  report = Find.new(**parsed[:find]).call
182
+ record_find(report)
169
183
  offer = pick_offer(report, parsed[:json])
170
184
  return report[:offers].any? ? 0 : 1 unless offer
171
185
 
@@ -192,36 +206,84 @@ module Portage
192
206
  private_class_method :prompt_for_offer
193
207
 
194
208
  def self.execute_buy(parsed, url, product_id: nil)
195
- options = parsed[:buy].merge(url: url)
209
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
196
210
  options[:product_id] ||= product_id
197
211
  report = Buy.new(**options).call
198
- record_purchase(report, options[:query]) if report[:checkout]
212
+ record_buy(report, options[:query])
199
213
  puts parsed[:json] ? JSON.pretty_generate(report) : format_report(report)
200
214
  report[:checkout] || report[:browse] ? 0 : 1
201
215
  end
202
216
  private_class_method :execute_buy
203
217
 
204
- # Only checkout attempts land here — a browse-only report never reached a
205
- # checkout, so it belongs to search history, not purchase history.
206
- def self.record_purchase(report, query)
207
- History.new.record_purchase(
208
- url: report[:url], query: query, checkout: report[:checkout],
209
- checkout_status: report[:checkout_status], message: report[:message],
210
- 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]
211
261
  )
212
262
  end
213
- private_class_method :record_purchase
263
+ private_class_method :record_buy
214
264
 
265
+ def self.report_total(report)
266
+ Portage::Ucp::Support::Totals.amount(report[:totals])
267
+ end
268
+ private_class_method :report_total
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.
215
274
  def self.parse_buy_options(argv)
216
275
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
217
276
  buy = { url: url, qty: 1, yes: false, dry_run: false }
218
- parsed = { buy: buy, find: {} }
277
+ parsed = { buy: buy, find: {}, confidence: {} }
278
+ json = argv.include?("--json")
219
279
  buy_option_parser(buy, parsed).parse!(argv)
220
280
  buy[:query] ||= ""
221
281
  return parsed if url || !buy[:query].strip.empty?
222
282
 
223
283
  warn USAGE
224
284
  nil
285
+ rescue OptionParser::ParseError => e
286
+ invalid_buy_option(e.message, url: url, json: json)
225
287
  end
226
288
  private_class_method :parse_buy_options
227
289
 
@@ -232,12 +294,23 @@ module Portage
232
294
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
233
295
  parser.on("--yes") { buy[:yes] = true }
234
296
  parser.on("--dry-run") { buy[:dry_run] = true }
297
+ parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
298
+ parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
235
299
  parser.on("--json") { parsed[:json] = true }
236
300
  add_search_options(parser, buy, parsed)
301
+ add_confidence_options(parser, parsed[:confidence])
237
302
  end
238
303
  end
239
304
  private_class_method :buy_option_parser
240
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
+
241
314
  # `--query` feeds both halves: it's the store search when there's no URL
242
315
  # and the catalog search once a store is settled, so it's registered once
243
316
  # here rather than twice on the same parser.
@@ -306,13 +379,30 @@ module Portage
306
379
  end
307
380
  private_class_method :format_history
308
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.
309
384
  def self.history_purchase_line(entry)
310
- "#{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(" — ")
311
392
  end
312
393
  private_class_method :history_purchase_line
313
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
+
314
403
  def self.history_search_line(entry)
315
- "#{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)"
316
406
  end
317
407
  private_class_method :history_search_line
318
408
 
@@ -594,20 +684,51 @@ module Portage
594
684
  puts "Wrote #{result[:profile_path]} (kid #{result[:kid]})"
595
685
  puts "Wrote private key to #{result[:private_key_path]} — keep this out of version control " \
596
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."
597
689
  0
598
690
  end
599
691
  private_class_method :run_generate_agent_profile
600
692
 
601
693
  # --- output ---
602
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.
603
697
  def self.format_report(report)
604
- lines = ["#{report[:message]} (source: #{report[:source]})"]
698
+ lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
605
699
  report[:products].each { |p| lines << " - #{product_line(p)}" }
700
+ lines.concat(format_checkout(report))
606
701
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
702
+ lines.concat(format_handoff(report[:handoff])) if report[:handoff]
703
+ lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
607
704
  lines.join("\n")
608
705
  end
609
706
  private_class_method :format_report
610
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
+
725
+ def self.format_handoff(handoff)
726
+ lines = [" opened in browser: #{handoff[:opened]}", " notified: #{handoff[:notified]}"]
727
+ lines << " notify error: #{handoff[:notify_error]}" if handoff[:notify_error]
728
+ lines
729
+ end
730
+ private_class_method :format_handoff
731
+
611
732
  def self.product_line(product)
612
733
  product.respond_to?(:title) ? "#{product.id}: #{product.title}" : "#{product['id']}: #{product['title']}"
613
734
  end
@@ -644,8 +765,13 @@ module Portage
644
765
  def self.format_price(offer)
645
766
  return "price n/a" unless offer[:amount]
646
767
 
647
- "#{format('%.2f', offer[:amount] / 100.0)}#{" #{offer[:currency]}" if offer[:currency]}"
768
+ format_amount(offer[:amount], offer[:currency])
648
769
  end
649
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
650
776
  end
651
777
  end
metadata CHANGED
@@ -1,13 +1,14 @@
1
1
  --- !ruby/object:Gem::Specification
2
2
  name: portage-cli
3
3
  version: !ruby/object:Gem::Version
4
- version: 0.6.0
4
+ version: 0.7.0
5
5
  platform: ruby
6
6
  authors:
7
7
  - Tom Whitbread
8
+ autorequire:
8
9
  bindir: exe
9
10
  cert_chain: []
10
- date: 1980-01-02 00:00:00.000000000 Z
11
+ date: 2026-09-23 00:00:00.000000000 Z
11
12
  dependencies:
12
13
  - !ruby/object:Gem::Dependency
13
14
  name: portage-ucp
@@ -15,28 +16,34 @@ dependencies:
15
16
  requirements:
16
17
  - - "~>"
17
18
  - !ruby/object:Gem::Version
18
- version: '0.8'
19
+ version: '0.9'
19
20
  type: :runtime
20
21
  prerelease: false
21
22
  version_requirements: !ruby/object:Gem::Requirement
22
23
  requirements:
23
24
  - - "~>"
24
25
  - !ruby/object:Gem::Version
25
- version: '0.8'
26
+ version: '0.9'
26
27
  - !ruby/object:Gem::Dependency
27
28
  name: portage-ucp-client
28
29
  requirement: !ruby/object:Gem::Requirement
29
30
  requirements:
30
31
  - - "~>"
31
32
  - !ruby/object:Gem::Version
32
- version: '0.4'
33
+ version: '0.6'
34
+ - - ">="
35
+ - !ruby/object:Gem::Version
36
+ version: 0.6.2
33
37
  type: :runtime
34
38
  prerelease: false
35
39
  version_requirements: !ruby/object:Gem::Requirement
36
40
  requirements:
37
41
  - - "~>"
38
42
  - !ruby/object:Gem::Version
39
- version: '0.4'
43
+ version: '0.6'
44
+ - - ">="
45
+ - !ruby/object:Gem::Version
46
+ version: 0.6.2
40
47
  - !ruby/object:Gem::Dependency
41
48
  name: portage-ucp-journal
42
49
  requirement: !ruby/object:Gem::Requirement
@@ -118,7 +125,10 @@ description: 'Ships the `portage` executable. `portage buy <url>` tries native U
118
125
  no SERP scraping. Depends on portage-ucp (for platform detection via Resolver),
119
126
  portage-ucp-client (for the actual buy calls), and portage-ucp-journal (for `portage-console`''s
120
127
  read-only view of local purchase/transaction/order state); no single adapter gem
121
- 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.'
131
+ email:
122
132
  executables:
123
133
  - portage
124
134
  - portage-console
@@ -132,20 +142,27 @@ files:
132
142
  - exe/portage-console
133
143
  - lib/portage/cli.rb
134
144
  - lib/portage/cli/buy.rb
145
+ - lib/portage/cli/buyer_context.rb
135
146
  - lib/portage/cli/catalog_products.rb
147
+ - lib/portage/cli/checkout_handoff.rb
136
148
  - lib/portage/cli/compare.rb
149
+ - lib/portage/cli/confidence_check.rb
150
+ - lib/portage/cli/config.rb
137
151
  - lib/portage/cli/console.rb
152
+ - lib/portage/cli/decisions.rb
138
153
  - lib/portage/cli/doctor.rb
139
154
  - lib/portage/cli/find.rb
140
155
  - lib/portage/cli/generate/adapter.rb
141
156
  - lib/portage/cli/generate/agent_profile.rb
142
157
  - lib/portage/cli/history.rb
158
+ - lib/portage/cli/notifier.rb
143
159
  - lib/portage/cli/payment_methods.rb
144
160
  - lib/portage/cli/payment_methods/env_backend.rb
145
161
  - lib/portage/cli/payment_methods/keychain_backend.rb
146
162
  - lib/portage/cli/payment_methods/secret_service_backend.rb
147
163
  - lib/portage/cli/probe_cache.rb
148
164
  - lib/portage/cli/search_backends.rb
165
+ - lib/portage/cli/setting.rb
149
166
  - lib/portage/cli/shipping_profile.rb
150
167
  - lib/portage/cli/version.rb
151
168
  homepage: https://github.com/tomtom87/Portage/tree/main/portage-cli
@@ -155,6 +172,7 @@ metadata:
155
172
  source_code_uri: https://github.com/tomtom87/Portage/tree/main/portage-cli
156
173
  changelog_uri: https://github.com/tomtom87/Portage/blob/main/portage-cli/CHANGELOG.md
157
174
  rubygems_mfa_required: 'true'
175
+ post_install_message:
158
176
  rdoc_options: []
159
177
  require_paths:
160
178
  - lib
@@ -169,7 +187,8 @@ required_rubygems_version: !ruby/object:Gem::Requirement
169
187
  - !ruby/object:Gem::Version
170
188
  version: '0'
171
189
  requirements: []
172
- rubygems_version: 4.0.21
190
+ rubygems_version: 3.5.22
191
+ signing_key:
173
192
  specification_version: 4
174
193
  summary: portage — one CLI command to buy from any store, native UCP or not
175
194
  test_files: []