portage-cli 0.9.0 → 0.11.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 +64 -0
  3. data/README.md +33 -4
  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 +3 -24
  9. data/lib/portage/cli/check.rb +164 -0
  10. data/lib/portage/cli/check_next_step.rb +51 -0
  11. data/lib/portage/cli/classifier/ranking.rb +135 -0
  12. data/lib/portage/cli/classifier/table.rb +63 -0
  13. data/lib/portage/cli/classifier.rb +23 -44
  14. data/lib/portage/cli/doctor.rb +16 -6
  15. data/lib/portage/cli/find.rb +5 -3
  16. data/lib/portage/cli/handoff_host.rb +33 -0
  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 +142 -12
  34. metadata +34 -4
@@ -1,12 +1,13 @@
1
- require "json"
2
- require "fileutils"
3
1
  require "uri"
2
+ require_relative "database"
4
3
 
5
4
  module Portage
6
5
  module Cli
7
6
  module Index
8
- # `~/.portage/index/stores.json` — one entry per merchant origin
9
- # `portage index build`/`refresh`/`add` has found and verified.
7
+ # The `stores` table of `~/.portage/index/index.sqlite3` (Index::Database;
8
+ # `stores.json` before docs/plans/local-catalogue.md Phase 1) — one
9
+ # entry per merchant origin `portage index build`/`refresh`/`add` has
10
+ # found and verified.
10
11
  #
11
12
  # This is untrusted, user-owned data, same posture as `stores.yml`
12
13
  # before it's tagged: never checked into git (docs/plans/
@@ -19,7 +20,7 @@ module Portage
19
20
  # search result (see search_backends_spec.rb/cli_spec.rb for the
20
21
  # specs proving both).
21
22
  #
22
- # Entry shape (string keys, JSON on disk): origin, platform,
23
+ # Entry shape (string keys, a JSON object per row): origin, platform,
23
24
  # ucp_version, capabilities (array of "catalog"/"cart"/"checkout"),
24
25
  # webmcp_preset, categories (category id => weight, top 5),
25
26
  # sources (array of source names that found it), last_verified
@@ -27,45 +28,49 @@ module Portage
27
28
  class Store
28
29
  PATH = File.join(Dir.home, ".portage", "index", "stores.json").freeze
29
30
 
31
+ # @param path [String] where the legacy stores.json lives (or would
32
+ # live) — the database sits beside it, and a stores.json found
33
+ # there is imported on first open.
30
34
  def initialize(path: PATH)
31
- @path = path
35
+ @db = Database.new(path: Database.path_for(path))
32
36
  end
33
37
 
34
- def all = entries.values
38
+ def all = @db.entries("stores").values
35
39
 
36
- def find(origin) = entries[origin]
40
+ def find(origin) = @db.get("stores", origin)
37
41
 
38
42
  # Merges `fields` onto whatever's already there for `origin` (or
39
43
  # starts a fresh entry) — so a second source finding the same store
40
44
  # adds to its `sources`/`categories` rather than clobbering the
41
45
  # first source's findings.
42
46
  def upsert(origin, **fields)
43
- existing = entries[origin] || { "origin" => origin }
44
- entries[origin] = existing.merge(fields.transform_keys(&:to_s))
45
- write
46
- entries[origin]
47
+ @db.transaction do
48
+ existing = @db.get("stores", origin) || { "origin" => origin }
49
+ existing.merge(fields.transform_keys(&:to_s)).tap { |merged| @db.put("stores", origin, merged) }
50
+ end
47
51
  end
48
52
 
49
53
  # @return [Integer] how many entries (0 or 1 — origins are unique)
50
54
  # this host's removal actually dropped.
51
55
  def remove(host)
52
- before = entries.length
53
- entries.reject! { |origin, _| host_of(origin) == host }
54
- write
55
- before - entries.length
56
+ @db.transaction do
57
+ doomed = @db.entries("stores").keys.select { |origin| host_of(origin) == host }
58
+ doomed.each { |origin| @db.delete("stores", origin) }
59
+ doomed.length
60
+ end
56
61
  end
57
62
 
58
63
  # @return [Integer, nil] seconds since the oldest entry was
59
64
  # verified — nil when the index is empty. `doctor` and `index
60
65
  # show` use this to say how stale the local index is.
61
66
  def oldest_verified_age(now: Time.now)
62
- timestamps = entries.values.filter_map { |e| e["last_verified"] }
67
+ timestamps = all.filter_map { |e| e["last_verified"] }
63
68
  return nil if timestamps.empty?
64
69
 
65
70
  now.to_i - timestamps.min
66
71
  end
67
72
 
68
- def exists? = File.exist?(@path)
73
+ def exists? = @db.exists?
69
74
 
70
75
  private
71
76
 
@@ -74,35 +79,6 @@ module Portage
74
79
  rescue URI::InvalidURIError
75
80
  ""
76
81
  end
77
-
78
- def entries
79
- @entries ||= read
80
- end
81
-
82
- def read
83
- return {} unless File.readable?(@path)
84
-
85
- # A business name/title can carry non-ASCII bytes (curly quotes,
86
- # ®, accents); reading with the process's default external
87
- # encoding (US-ASCII on a bare-minimal LANG, confirmed live in a
88
- # sandbox with no locale set) would otherwise raise on the very
89
- # first non-ASCII byte and get silently swallowed below, quietly
90
- # dropping the whole file's worth of entries — this was caught by
91
- # this phase's own live index-build check.
92
- parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
93
- parsed.is_a?(Hash) ? parsed : {}
94
- rescue StandardError
95
- {}
96
- end
97
-
98
- # An index that can't be written just means this run's findings
99
- # aren't saved — never a failed build.
100
- def write
101
- FileUtils.mkdir_p(File.dirname(@path))
102
- File.write(@path, JSON.generate(@entries))
103
- rescue StandardError
104
- nil
105
- end
106
82
  end
107
83
  end
108
84
  end
@@ -1,5 +1,6 @@
1
1
  require_relative "index/store"
2
2
  require_relative "index/product_store"
3
+ require_relative "index/entry_product"
3
4
  require_relative "index/known_cache"
4
5
  require_relative "index/sources"
5
6
  require_relative "index/builder"
@@ -49,6 +49,19 @@ module Portage
49
49
 
50
50
  def self.retail_handoff_host?(host) = HandoffOnly.matches_any?(host, RETAIL_HANDOFF_HOSTS)
51
51
 
52
+ # Adds the UCP Product wire hash to an offer as `product` (docs/plans/
53
+ # local-catalogue.md Phase 3), as the store served it, with `media`
54
+ # cut to the first image. Shared by Find and ShopifyCatalog so both
55
+ # trim the same way. Anything that isn't a non-empty hash leaves the
56
+ # offer as it was: the field is left out, not faked.
57
+ def self.with_product(offer, product)
58
+ return offer unless product.is_a?(Hash) && !product.empty?
59
+
60
+ media = product["media"]
61
+ product = product.merge("media" => media.first(1)) if media.is_a?(Array) && media.length > 1
62
+ offer.merge(product: product)
63
+ end
64
+
52
65
  # Shared by every retailer source below: an offer's `store` is the
53
66
  # origin of the item URL the API itself returned, same idea as
54
67
  # ShopifyCatalog#origin_of — kept as one module method instead of
@@ -133,8 +146,10 @@ module Portage
133
146
  return nil unless origin
134
147
 
135
148
  amount, currency = price_of(product)
136
- { store: origin, source: name, checkout: nil, product_id: variant["id"], title: product["title"],
137
- amount: amount, currency: currency, url: variant["url"] }
149
+ OfferSources.with_product(
150
+ { store: origin, source: name, checkout: nil, product_id: variant["id"], title: product["title"],
151
+ amount: amount, currency: currency, url: variant["url"] }, product
152
+ )
138
153
  end
139
154
 
140
155
  def origin_of(url)
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.9.0".freeze
3
+ VERSION = "0.11.0".freeze
4
4
  end
5
5
  end
data/lib/portage/cli.rb CHANGED
@@ -15,6 +15,7 @@ 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"
19
20
  require_relative "cli/quotes"
20
21
  require_relative "cli/money"
@@ -56,6 +57,7 @@ module Portage
56
57
  portage find --query "..." [--max-price N] [--limit N] [--json]
57
58
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
58
59
  [--max-price N] [--json]
60
+ portage check <url> [--json]
59
61
  portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
60
62
  [--choose REF | --compare REF | --view REF]
61
63
  portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
@@ -77,8 +79,9 @@ module Portage
77
79
  portage orders reconcile [--checkout ID] [--json]
78
80
  portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
79
81
  portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
80
- portage index show [--stores|--products] [--json]
81
- portage index add <url> [--json]
82
+ portage index show [--stores|--products [--page N] [--per-page N]] [--json]
83
+ portage index search QUERY [--category ID] [--store HOST] [--limit N] [--json]
84
+ portage index add <url> [--crawl] [--json]
82
85
  portage index remove <host> [--json]
83
86
  portage index sources [--json]
84
87
  portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
@@ -93,13 +96,13 @@ module Portage
93
96
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
94
97
  portage --version
95
98
 
96
- proxy flags (buy/find/compare/doctor/payment enroll):
99
+ proxy flags (buy/find/compare/check/doctor/payment enroll):
97
100
  [--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
98
101
  [--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
99
102
  [--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
100
103
  USAGE
101
104
 
102
- COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
105
+ COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare, "check" => :run_check,
103
106
  "pick" => :run_pick, "approve" => :run_approve,
104
107
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
105
108
  "orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
@@ -207,6 +210,54 @@ module Portage
207
210
  def self.to_minor_units(major) = (major * 100).round
208
211
  private_class_method :to_minor_units
209
212
 
213
+ # --- check ---
214
+
215
+ # `portage check <url>` — can Portage buy from this store, and how?
216
+ # Exit 0 for `automated`/`webmcp`, 1 for anything else (and for a bad
217
+ # or missing URL, with the usage on stderr).
218
+ def self.run_check(argv)
219
+ opts = parse_check_options(argv)
220
+ return 1 unless opts
221
+ return 1 unless apply_proxy_settings(opts[:proxy])
222
+
223
+ report = Check.new(opts[:url]).call
224
+ puts opts[:json] ? JSON.pretty_generate(report) : format_check(report)
225
+ Check::USABLE_VERDICTS.include?(report[:verdict]) ? 0 : 1
226
+ end
227
+ private_class_method :run_check
228
+
229
+ def self.parse_check_options(argv)
230
+ opts = { proxy: {} }
231
+ OptionParser.new do |parser|
232
+ parser.on("--json") { opts[:json] = true }
233
+ ProxySettings.add_options(parser, opts[:proxy])
234
+ end.parse!(argv)
235
+ opts[:url] = argv.shift.to_s.strip
236
+ return opts if !opts[:url].empty? && argv.empty? && valid_check_url?(opts[:url])
237
+
238
+ warn USAGE
239
+ nil
240
+ end
241
+ private_class_method :parse_check_options
242
+
243
+ def self.valid_check_url?(url)
244
+ uri = URI.parse(url =~ %r{\Ahttps?://}i ? url : "https://#{url}")
245
+ uri.is_a?(URI::HTTP) && !uri.host.to_s.empty?
246
+ rescue URI::InvalidURIError
247
+ false
248
+ end
249
+ private_class_method :valid_check_url?
250
+
251
+ def self.format_check(report)
252
+ lines = ["#{report[:url]}: #{report[:verdict]}"]
253
+ lines << " platform: #{report[:platform]}" if report[:platform]
254
+ lines << " webmcp: #{report[:webmcp][:status]}#{" (#{report[:webmcp][:reason]})" if report[:webmcp][:reason]}"
255
+ lines << " next: #{report[:next_step]}"
256
+ lines << " index: `#{report[:index_hint]}` adds its catalogue to your local index" if report[:index_hint]
257
+ lines.join("\n")
258
+ end
259
+ private_class_method :format_check
260
+
210
261
  # --- compare ---
211
262
 
212
263
  def self.run_compare(argv)
@@ -1312,6 +1363,7 @@ module Portage
1312
1363
  "build" => ->(argv) { run_index_build(argv, refresh: false) },
1313
1364
  "refresh" => ->(argv) { run_index_build(argv, refresh: true) },
1314
1365
  "show" => ->(argv) { run_index_show(argv) },
1366
+ "search" => ->(argv) { run_index_search(argv) },
1315
1367
  "add" => ->(argv) { run_index_add(argv) },
1316
1368
  "remove" => ->(argv) { run_index_remove(argv) },
1317
1369
  "sources" => ->(argv) { run_index_sources(argv) }
@@ -1370,41 +1422,119 @@ module Portage
1370
1422
  end
1371
1423
  private_class_method :format_index_build
1372
1424
 
1425
+ INDEX_SHOW_PER_PAGE = 50
1426
+
1373
1427
  def self.run_index_show(argv)
1374
- opts = { kind: nil, json: false }
1428
+ opts = { kind: nil, json: false, page: 1, per_page: INDEX_SHOW_PER_PAGE }
1375
1429
  OptionParser.new do |parser|
1376
1430
  parser.on("--stores") { opts[:kind] = "stores" }
1377
1431
  parser.on("--products") { opts[:kind] = "products" }
1432
+ parser.on("--page N", Integer) { |v| opts[:page] = [v, 1].max }
1433
+ parser.on("--per-page N", Integer) { |v| opts[:per_page] = [v, 1].max }
1378
1434
  parser.on("--json") { opts[:json] = true }
1379
1435
  end.parse!(argv)
1380
1436
 
1381
- result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
1382
- products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
1437
+ result = opts[:kind] == "products" ? index_products_page(opts) : index_show_all(opts[:kind])
1383
1438
  puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
1384
1439
  0
1385
1440
  end
1386
1441
  private_class_method :run_index_show
1387
1442
 
1443
+ def self.index_show_all(kind)
1444
+ { stores: kind == "products" ? [] : Index::Store.new.all, products: kind == "stores" ? [] : Index::ProductStore.new.all }
1445
+ end
1446
+ private_class_method :index_show_all
1447
+
1448
+ # `--products` pages (docs/plans/local-catalogue.md Phase 2): a crawled
1449
+ # index can hold thousands.
1450
+ def self.index_products_page(opts)
1451
+ products = Index::ProductStore.new
1452
+ { stores: [], products: products.page(opts[:page], per_page: opts[:per_page]), page: opts[:page],
1453
+ per_page: opts[:per_page], products_total: products.count }
1454
+ end
1455
+ private_class_method :index_products_page
1456
+
1388
1457
  def self.format_index_show(result)
1389
- lines = ["Stores:"]
1390
- result[:stores].each { |s| lines << " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1391
- lines << "(none)" if result[:stores].empty?
1458
+ paged = result.key?(:page)
1459
+ lines = paged ? [] : index_store_lines(result[:stores])
1392
1460
  lines << "Products:"
1393
1461
  result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
1394
1462
  lines << "(none)" if result[:products].empty?
1463
+ lines << index_page_line(result) if paged
1395
1464
  lines.join("\n")
1396
1465
  end
1397
1466
  private_class_method :format_index_show
1398
1467
 
1468
+ def self.index_store_lines(stores)
1469
+ lines = ["Stores:"] + stores.map { |s| " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1470
+ stores.empty? ? lines + ["(none)"] : lines
1471
+ end
1472
+ private_class_method :index_store_lines
1473
+
1474
+ def self.index_page_line(result)
1475
+ first = ((result[:page] - 1) * result[:per_page]) + 1
1476
+ last = first + result[:products].length - 1
1477
+ return "Page #{result[:page]} is past the end (#{result[:products_total]} product(s))." if last < first
1478
+
1479
+ "Showing #{first}-#{last} of #{result[:products_total]}. Next: --page #{result[:page] + 1}" \
1480
+ "#{" --per-page #{result[:per_page]}" unless result[:per_page] == INDEX_SHOW_PER_PAGE}"
1481
+ end
1482
+ private_class_method :index_page_line
1483
+
1484
+ # `portage index search` (docs/plans/local-catalogue.md Phase 2): local
1485
+ # index entries only, no request. Exits 1 with no hits.
1486
+ def self.run_index_search(argv)
1487
+ opts = { category: nil, store: nil, limit: 20, json: false }
1488
+ OptionParser.new do |parser|
1489
+ parser.on("--category ID") { |v| opts[:category] = v }
1490
+ parser.on("--store HOST") { |v| opts[:store] = v }
1491
+ parser.on("--limit N", Integer) { |v| opts[:limit] = [v, 1].max }
1492
+ parser.on("--json") { opts[:json] = true }
1493
+ end.parse!(argv)
1494
+ query = argv.join(" ").strip
1495
+ return (warn USAGE) || 1 if query.empty?
1496
+
1497
+ result = index_search(query, opts)
1498
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_search(result)
1499
+ result[:products].empty? ? 1 : 0
1500
+ end
1501
+ private_class_method :run_index_search
1502
+
1503
+ def self.index_search(query, opts)
1504
+ products = Index::ProductStore.new
1505
+ filters = opts.slice(:category, :store, :limit)
1506
+ hits = products.search(query, **filters).map { |entry| entry.merge("product" => Index::EntryProduct.wire(entry)) }
1507
+ { query: query, engine: products.search_engine, live: false, filters: filters, products: hits }
1508
+ end
1509
+ private_class_method :index_search
1510
+
1511
+ def self.format_index_search(result)
1512
+ return "No index products match \"#{result[:query]}\"." if result[:products].empty?
1513
+
1514
+ lines = result[:products].map { |p| index_search_line(p) }
1515
+ lines << "(From the local index, not live: check the price and stock with `portage find --store`.)"
1516
+ lines << "(FTS5 isn't available in this SQLite, so this was a plain text match.)" if result[:engine] == "like"
1517
+ lines.join("\n")
1518
+ end
1519
+ private_class_method :format_index_search
1520
+
1521
+ def self.index_search_line(product)
1522
+ hosts = Array(product["stores"]).filter_map { |s| URI.parse(s["origin"].to_s).host }.join(", ")
1523
+ ["#{product['title']}#{" — #{product['brand']}" if product['brand']}", ("(#{hosts})" unless hosts.empty?),
1524
+ product["url"]].compact.join(" ")
1525
+ end
1526
+ private_class_method :index_search_line
1527
+
1399
1528
  def self.run_index_add(argv)
1400
- json = argv.delete("--json") ? true : false
1529
+ json = !argv.delete("--json").nil?
1530
+ crawl = !argv.delete("--crawl").nil?
1401
1531
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1402
1532
  unless url
1403
1533
  warn USAGE
1404
1534
  return 1
1405
1535
  end
1406
1536
 
1407
- result = Index::Builder.new.add(url)
1537
+ result = Index::Builder.new.add(url, crawl: crawl)
1408
1538
  puts json ? JSON.pretty_generate(result) : result[:message]
1409
1539
  result[:added] ? 0 : 1
1410
1540
  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.9.0
4
+ version: 0.11.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-09-30 00:00:00.000000000 Z
12
12
  dependencies:
13
13
  - !ruby/object:Gem::Dependency
14
14
  name: irb
@@ -30,14 +30,14 @@ dependencies:
30
30
  requirements:
31
31
  - - "~>"
32
32
  - !ruby/object:Gem::Version
33
- version: '0.10'
33
+ version: '0.11'
34
34
  type: :runtime
35
35
  prerelease: false
36
36
  version_requirements: !ruby/object:Gem::Requirement
37
37
  requirements:
38
38
  - - "~>"
39
39
  - !ruby/object:Gem::Version
40
- version: '0.10'
40
+ version: '0.11'
41
41
  - !ruby/object:Gem::Dependency
42
42
  name: portage-ucp-client
43
43
  requirement: !ruby/object:Gem::Requirement
@@ -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
@@ -184,8 +200,12 @@ files:
184
200
  - lib/portage/cli/buy.rb
185
201
  - lib/portage/cli/buyer_context.rb
186
202
  - lib/portage/cli/catalog_products.rb
203
+ - lib/portage/cli/check.rb
204
+ - lib/portage/cli/check_next_step.rb
187
205
  - lib/portage/cli/checkout_handoff.rb
188
206
  - lib/portage/cli/classifier.rb
207
+ - lib/portage/cli/classifier/ranking.rb
208
+ - lib/portage/cli/classifier/table.rb
189
209
  - lib/portage/cli/compare.rb
190
210
  - lib/portage/cli/confidence_check.rb
191
211
  - lib/portage/cli/config.rb
@@ -197,6 +217,7 @@ files:
197
217
  - lib/portage/cli/generate/adapter.rb
198
218
  - lib/portage/cli/generate/agent_profile.rb
199
219
  - lib/portage/cli/handoff_agents.rb
220
+ - lib/portage/cli/handoff_host.rb
200
221
  - lib/portage/cli/handoff_only.rb
201
222
  - lib/portage/cli/handoff_reconciler.rb
202
223
  - lib/portage/cli/handoff_reconciler/wire_adapters.rb
@@ -209,12 +230,21 @@ files:
209
230
  - lib/portage/cli/human_prompt.rb
210
231
  - lib/portage/cli/index.rb
211
232
  - lib/portage/cli/index/builder.rb
233
+ - lib/portage/cli/index/database.rb
234
+ - lib/portage/cli/index/entry_product.rb
212
235
  - lib/portage/cli/index/exporter.rb
213
236
  - lib/portage/cli/index/known_cache.rb
237
+ - lib/portage/cli/index/legacy_import.rb
214
238
  - lib/portage/cli/index/product_store.rb
239
+ - lib/portage/cli/index/schema.rb
240
+ - lib/portage/cli/index/search.rb
215
241
  - lib/portage/cli/index/sources.rb
216
242
  - lib/portage/cli/index/sources/browser.rb
217
243
  - lib/portage/cli/index/sources/shopify_catalog.rb
244
+ - lib/portage/cli/index/sources/storefront_products.rb
245
+ - lib/portage/cli/index/sources/storefront_products/mapper.rb
246
+ - lib/portage/cli/index/sources/storefront_products/pages.rb
247
+ - lib/portage/cli/index/sources/storefront_products/robots.rb
218
248
  - lib/portage/cli/index/sources/stores_file.rb
219
249
  - lib/portage/cli/index/sources/webmcp_sweep.rb
220
250
  - lib/portage/cli/index/sources/wikidata.rb