portage-cli 0.10.0 → 0.12.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.
Files changed (34) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +129 -0
  3. data/README.md +107 -21
  4. data/known-stores/categories.yml +6841 -18
  5. data/known-stores/category-stoplist.yml +40 -0
  6. data/known-stores/category-synonyms.yml +15 -0
  7. data/lib/portage/cli/browser_import/categorize.rb +5 -2
  8. data/lib/portage/cli/buy.rb +217 -39
  9. data/lib/portage/cli/check.rb +11 -1
  10. data/lib/portage/cli/classifier/ranking.rb +135 -0
  11. data/lib/portage/cli/classifier/table.rb +63 -0
  12. data/lib/portage/cli/classifier.rb +23 -44
  13. data/lib/portage/cli/confidence_check.rb +27 -7
  14. data/lib/portage/cli/confidence_state.rb +109 -0
  15. data/lib/portage/cli/doctor.rb +16 -6
  16. data/lib/portage/cli/find.rb +69 -4
  17. data/lib/portage/cli/index/builder.rb +49 -12
  18. data/lib/portage/cli/index/database.rb +150 -0
  19. data/lib/portage/cli/index/entry_product.rb +37 -0
  20. data/lib/portage/cli/index/legacy_import.rb +54 -0
  21. data/lib/portage/cli/index/product_store.rb +73 -35
  22. data/lib/portage/cli/index/schema.rb +70 -0
  23. data/lib/portage/cli/index/search.rb +73 -0
  24. data/lib/portage/cli/index/sources/storefront_products/mapper.rb +127 -0
  25. data/lib/portage/cli/index/sources/storefront_products/pages.rb +114 -0
  26. data/lib/portage/cli/index/sources/storefront_products/robots.rb +70 -0
  27. data/lib/portage/cli/index/sources/storefront_products.rb +147 -0
  28. data/lib/portage/cli/index/sources.rb +5 -2
  29. data/lib/portage/cli/index/store.rb +23 -47
  30. data/lib/portage/cli/index.rb +1 -0
  31. data/lib/portage/cli/offer_sources.rb +17 -2
  32. data/lib/portage/cli/version.rb +1 -1
  33. data/lib/portage/cli.rb +111 -12
  34. metadata +30 -2
data/lib/portage/cli.rb CHANGED
@@ -55,6 +55,7 @@ module Portage
55
55
  portage buy --quote QUOTE_ID --yes [--json] ...
56
56
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
57
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
58
+ portage find --store URL --query "..." [--max-price N] [--json] (one store, live, read-only)
58
59
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
59
60
  [--max-price N] [--json]
60
61
  portage check <url> [--json]
@@ -79,8 +80,9 @@ module Portage
79
80
  portage orders reconcile [--checkout ID] [--json]
80
81
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
81
82
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
82
- portage index show [--stores|--products] [--json]
83
- portage index add <url> [--json]
83
+ portage index show [--stores|--products [--page N] [--per-page N]] [--json]
84
+ portage index search QUERY [--category ID] [--store HOST] [--limit N] [--json]
85
+ portage index add <url> [--crawl] [--json]
84
86
  portage index remove <host> [--json]
85
87
  portage index sources [--json]
86
88
  portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
@@ -187,14 +189,29 @@ module Portage
187
189
  return nil
188
190
  end
189
191
 
190
- opts
192
+ valid_find_store?(opts) ? opts : nil
191
193
  end
192
194
  private_class_method :parse_find_options
193
195
 
196
+ # `--store` must be an http(s) URL (a bare host is read as https); anything
197
+ # else is a usage error rather than a silent no-op search.
198
+ def self.valid_find_store?(opts)
199
+ return true unless opts.key?(:store)
200
+ return true if Find.store_origin(opts[:store])
201
+
202
+ warn "portage: --store needs an http(s) store URL, got #{opts[:store].inspect}."
203
+ warn USAGE
204
+ false
205
+ end
206
+ private_class_method :valid_find_store?
207
+
194
208
  def self.find_option_parser(opts)
195
209
  opts[:proxy] = {}
196
210
  OptionParser.new do |parser|
197
211
  parser.on("--query QUERY") { |v| opts[:query] = v }
212
+ parser.on("--store URL", "Search only this store's catalogue, live and read-only (http/https)") do |v|
213
+ opts[:store] = v
214
+ end
198
215
  parser.on("--limit N", Integer) { |v| opts[:limit] = v }
199
216
  parser.on("--max-price N", Float) { |v| opts[:max_price] = to_minor_units(v) }
200
217
  parser.on("--json") { opts[:json] = true }
@@ -252,6 +269,7 @@ module Portage
252
269
  lines << " platform: #{report[:platform]}" if report[:platform]
253
270
  lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
254
271
  lines << " next: #{report[:next_step]}"
272
+ lines << " index: `#{report[:index_hint]}` adds its catalogue to your local index" if report[:index_hint]
255
273
  lines.join("\n")
256
274
  end
257
275
  private_class_method :format_check
@@ -412,7 +430,8 @@ module Portage
412
430
  parsed[:quote_record] = quote
413
431
  buy = parsed[:buy]
414
432
  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"])
433
+ buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"], quote_store: quote["store"],
434
+ quote_title: quote["title"])
416
435
  execute_buy(parsed, quote["store"])
417
436
  end
418
437
  private_class_method :buy_from_quote
@@ -1361,6 +1380,7 @@ module Portage
1361
1380
  "build" => ->(argv) { run_index_build(argv, refresh: false) },
1362
1381
  "refresh" => ->(argv) { run_index_build(argv, refresh: true) },
1363
1382
  "show" => ->(argv) { run_index_show(argv) },
1383
+ "search" => ->(argv) { run_index_search(argv) },
1364
1384
  "add" => ->(argv) { run_index_add(argv) },
1365
1385
  "remove" => ->(argv) { run_index_remove(argv) },
1366
1386
  "sources" => ->(argv) { run_index_sources(argv) }
@@ -1419,41 +1439,120 @@ module Portage
1419
1439
  end
1420
1440
  private_class_method :format_index_build
1421
1441
 
1442
+ INDEX_SHOW_PER_PAGE = 50
1443
+
1422
1444
  def self.run_index_show(argv)
1423
- opts = { kind: nil, json: false }
1445
+ opts = { kind: nil, json: false, page: 1, per_page: INDEX_SHOW_PER_PAGE }
1424
1446
  OptionParser.new do |parser|
1425
1447
  parser.on("--stores") { opts[:kind] = "stores" }
1426
1448
  parser.on("--products") { opts[:kind] = "products" }
1449
+ parser.on("--page N", Integer) { |v| opts[:page] = [v, 1].max }
1450
+ parser.on("--per-page N", Integer) { |v| opts[:per_page] = [v, 1].max }
1427
1451
  parser.on("--json") { opts[:json] = true }
1428
1452
  end.parse!(argv)
1429
1453
 
1430
- result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
1431
- products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
1454
+ result = opts[:kind] == "products" ? index_products_page(opts) : index_show_all(opts[:kind])
1432
1455
  puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
1433
1456
  0
1434
1457
  end
1435
1458
  private_class_method :run_index_show
1436
1459
 
1460
+ def self.index_show_all(kind)
1461
+ { stores: kind == "products" ? [] : Index::Store.new.all, products: kind == "stores" ? [] : Index::ProductStore.new.all }
1462
+ end
1463
+ private_class_method :index_show_all
1464
+
1465
+ # `--products` pages (docs/plans/local-catalogue.md Phase 2): a crawled
1466
+ # index can hold thousands.
1467
+ def self.index_products_page(opts)
1468
+ products = Index::ProductStore.new
1469
+ { stores: [], products: products.page(opts[:page], per_page: opts[:per_page]), page: opts[:page],
1470
+ per_page: opts[:per_page], products_total: products.count }
1471
+ end
1472
+ private_class_method :index_products_page
1473
+
1437
1474
  def self.format_index_show(result)
1438
- lines = ["Stores:"]
1439
- result[:stores].each { |s| lines << " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1440
- lines << "(none)" if result[:stores].empty?
1475
+ paged = result.key?(:page)
1476
+ lines = paged ? [] : index_store_lines(result[:stores])
1441
1477
  lines << "Products:"
1442
1478
  result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
1443
1479
  lines << "(none)" if result[:products].empty?
1480
+ lines << index_page_line(result) if paged
1444
1481
  lines.join("\n")
1445
1482
  end
1446
1483
  private_class_method :format_index_show
1447
1484
 
1485
+ def self.index_store_lines(stores)
1486
+ lines = ["Stores:"] + stores.map { |s| " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1487
+ stores.empty? ? lines + ["(none)"] : lines
1488
+ end
1489
+ private_class_method :index_store_lines
1490
+
1491
+ def self.index_page_line(result)
1492
+ first = ((result[:page] - 1) * result[:per_page]) + 1
1493
+ last = first + result[:products].length - 1
1494
+ return "Page #{result[:page]} is past the end (#{result[:products_total]} product(s))." if last < first
1495
+
1496
+ "Showing #{first}-#{last} of #{result[:products_total]}. Next: --page #{result[:page] + 1}" \
1497
+ "#{" --per-page #{result[:per_page]}" unless result[:per_page] == INDEX_SHOW_PER_PAGE}"
1498
+ end
1499
+ private_class_method :index_page_line
1500
+
1501
+ # `portage index search` (docs/plans/local-catalogue.md Phase 2): local
1502
+ # index entries only, no request. Exits 1 with no hits.
1503
+ def self.run_index_search(argv)
1504
+ opts = { category: nil, store: nil, limit: 20, json: false }
1505
+ OptionParser.new do |parser|
1506
+ parser.on("--category ID") { |v| opts[:category] = v }
1507
+ parser.on("--store HOST") { |v| opts[:store] = v }
1508
+ parser.on("--limit N", Integer) { |v| opts[:limit] = [v, 1].max }
1509
+ parser.on("--json") { opts[:json] = true }
1510
+ end.parse!(argv)
1511
+ query = argv.join(" ").strip
1512
+ return (warn USAGE) || 1 if query.empty?
1513
+
1514
+ result = index_search(query, opts)
1515
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_search(result)
1516
+ result[:products].empty? ? 1 : 0
1517
+ end
1518
+ private_class_method :run_index_search
1519
+
1520
+ def self.index_search(query, opts)
1521
+ products = Index::ProductStore.new
1522
+ filters = opts.slice(:category, :store, :limit)
1523
+ hits = products.search(query, **filters).map { |entry| entry.merge("product" => Index::EntryProduct.wire(entry)) }
1524
+ { query: query, engine: products.search_engine, live: false, filters: filters, products: hits }
1525
+ end
1526
+ private_class_method :index_search
1527
+
1528
+ def self.format_index_search(result)
1529
+ return "No index products match \"#{result[:query]}\"." if result[:products].empty?
1530
+
1531
+ lines = result[:products].map { |p| index_search_line(p) }
1532
+ lines << "(From the local index, not live: check the price and stock with " \
1533
+ "`portage find --store URL --query ...`.)"
1534
+ lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
1535
+ lines.join("\n")
1536
+ end
1537
+ private_class_method :format_index_search
1538
+
1539
+ def self.index_search_line(product)
1540
+ hosts = Array(product["stores"]).filter_map { |s| URI.parse(s["origin"].to_s).host }.join(", ")
1541
+ ["#{product['title']}#{" — #{product['brand']}" if product['brand']}", ("(#{hosts})" unless hosts.empty?),
1542
+ product["url"]].compact.join(" ")
1543
+ end
1544
+ private_class_method :index_search_line
1545
+
1448
1546
  def self.run_index_add(argv)
1449
- json = argv.delete("--json") ? true : false
1547
+ json = !argv.delete("--json").nil?
1548
+ crawl = !argv.delete("--crawl").nil?
1450
1549
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1451
1550
  unless url
1452
1551
  warn USAGE
1453
1552
  return 1
1454
1553
  end
1455
1554
 
1456
- result = Index::Builder.new.add(url)
1555
+ result = Index::Builder.new.add(url, crawl: crawl)
1457
1556
  puts json ? JSON.pretty_generate(result) : result[:message]
1458
1557
  result[:added] ? 0 : 1
1459
1558
  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.10.0
4
+ version: 0.12.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-29 00:00:00.000000000 Z
11
+ date: 2026-10-01 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: irb
@@ -72,6 +72,20 @@ dependencies:
72
72
  - - "~>"
73
73
  - !ruby/object:Gem::Version
74
74
  version: '0.1'
75
+ - !ruby/object:Gem::Dependency
76
+ name: sqlite3
77
+ requirement: !ruby/object:Gem::Requirement
78
+ requirements:
79
+ - - "~>"
80
+ - !ruby/object:Gem::Version
81
+ version: '2.9'
82
+ type: :runtime
83
+ prerelease: false
84
+ version_requirements: !ruby/object:Gem::Requirement
85
+ requirements:
86
+ - - "~>"
87
+ - !ruby/object:Gem::Version
88
+ version: '2.9'
75
89
  - !ruby/object:Gem::Dependency
76
90
  name: rspec
77
91
  requirement: !ruby/object:Gem::Requirement
@@ -155,6 +169,8 @@ files:
155
169
  - exe/portage
156
170
  - exe/portage-console
157
171
  - known-stores/categories.yml
172
+ - known-stores/category-stoplist.yml
173
+ - known-stores/category-synonyms.yml
158
174
  - lib/portage/cli.rb
159
175
  - lib/portage/cli/agent_profile_url.rb
160
176
  - lib/portage/cli/approval_policy.rb
@@ -188,8 +204,11 @@ files:
188
204
  - lib/portage/cli/check_next_step.rb
189
205
  - lib/portage/cli/checkout_handoff.rb
190
206
  - lib/portage/cli/classifier.rb
207
+ - lib/portage/cli/classifier/ranking.rb
208
+ - lib/portage/cli/classifier/table.rb
191
209
  - lib/portage/cli/compare.rb
192
210
  - lib/portage/cli/confidence_check.rb
211
+ - lib/portage/cli/confidence_state.rb
193
212
  - lib/portage/cli/config.rb
194
213
  - lib/portage/cli/console.rb
195
214
  - lib/portage/cli/decisions.rb
@@ -212,12 +231,21 @@ files:
212
231
  - lib/portage/cli/human_prompt.rb
213
232
  - lib/portage/cli/index.rb
214
233
  - lib/portage/cli/index/builder.rb
234
+ - lib/portage/cli/index/database.rb
235
+ - lib/portage/cli/index/entry_product.rb
215
236
  - lib/portage/cli/index/exporter.rb
216
237
  - lib/portage/cli/index/known_cache.rb
238
+ - lib/portage/cli/index/legacy_import.rb
217
239
  - lib/portage/cli/index/product_store.rb
240
+ - lib/portage/cli/index/schema.rb
241
+ - lib/portage/cli/index/search.rb
218
242
  - lib/portage/cli/index/sources.rb
219
243
  - lib/portage/cli/index/sources/browser.rb
220
244
  - lib/portage/cli/index/sources/shopify_catalog.rb
245
+ - lib/portage/cli/index/sources/storefront_products.rb
246
+ - lib/portage/cli/index/sources/storefront_products/mapper.rb
247
+ - lib/portage/cli/index/sources/storefront_products/pages.rb
248
+ - lib/portage/cli/index/sources/storefront_products/robots.rb
221
249
  - lib/portage/cli/index/sources/stores_file.rb
222
250
  - lib/portage/cli/index/sources/webmcp_sweep.rb
223
251
  - lib/portage/cli/index/sources/wikidata.rb