portage-cli 0.7.4 → 0.8.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 (73) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +580 -0
  3. data/README.md +322 -19
  4. data/exe/portage +5 -0
  5. data/exe/portage-console +3 -0
  6. data/known-stores/categories.yml +1263 -0
  7. data/lib/portage/cli/agent_profile_url.rb +30 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  21. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  22. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  23. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  24. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  25. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  26. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  27. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  28. data/lib/portage/cli/browser_profile.rb +25 -0
  29. data/lib/portage/cli/buy.rb +521 -29
  30. data/lib/portage/cli/classifier.rb +158 -0
  31. data/lib/portage/cli/compare.rb +3 -0
  32. data/lib/portage/cli/doctor.rb +195 -7
  33. data/lib/portage/cli/dot_env.rb +117 -0
  34. data/lib/portage/cli/find.rb +96 -12
  35. data/lib/portage/cli/handoff_agents.rb +186 -0
  36. data/lib/portage/cli/handoff_only.rb +94 -0
  37. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  38. data/lib/portage/cli/handoff_target.rb +61 -0
  39. data/lib/portage/cli/index/builder.rb +335 -0
  40. data/lib/portage/cli/index/exporter.rb +91 -0
  41. data/lib/portage/cli/index/known_cache.rb +155 -0
  42. data/lib/portage/cli/index/product_store.rb +101 -0
  43. data/lib/portage/cli/index/sources/browser.rb +31 -0
  44. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  45. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  46. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  47. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  48. data/lib/portage/cli/index/sources.rb +44 -0
  49. data/lib/portage/cli/index/store.rb +109 -0
  50. data/lib/portage/cli/index.rb +20 -0
  51. data/lib/portage/cli/known_stores_url.rb +15 -0
  52. data/lib/portage/cli/offer_sources.rb +460 -0
  53. data/lib/portage/cli/payment_methods.rb +24 -3
  54. data/lib/portage/cli/search_backends.rb +337 -12
  55. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  56. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  57. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  58. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  59. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  60. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  61. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  62. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  63. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  64. data/lib/portage/cli/setup_wizard.rb +74 -0
  65. data/lib/portage/cli/version.rb +1 -1
  66. data/lib/portage/cli/webmcp.rb +10 -3
  67. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  68. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  69. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  70. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  71. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  72. data/lib/portage/cli.rb +528 -6
  73. metadata +59 -2
data/lib/portage/cli.rb CHANGED
@@ -1,18 +1,27 @@
1
1
  require "optparse"
2
2
  require "json"
3
+ require "uri"
3
4
 
4
5
  require_relative "cli/version"
5
6
  require_relative "cli/user_agent"
6
7
  require_relative "cli/shipping_profile"
7
8
  require_relative "cli/buyer_context"
8
9
  require_relative "cli/catalog_products"
10
+ require_relative "cli/agent_profile_url"
11
+ require_relative "cli/offer_sources"
12
+ require_relative "cli/index"
13
+ require_relative "cli/browser_import"
14
+ require_relative "cli/browser_profile"
9
15
  require_relative "cli/buy"
10
16
  require_relative "cli/find"
11
17
  require_relative "cli/compare"
12
18
  require_relative "cli/history"
13
19
  require_relative "cli/payment_methods"
14
20
  require_relative "cli/proxy_settings"
21
+ require_relative "cli/handoff_only"
22
+ require_relative "cli/handoff_target"
15
23
  require_relative "cli/doctor"
24
+ require_relative "cli/setup_wizard"
16
25
  require_relative "cli/handoff_reconciler"
17
26
  require_relative "cli/handoff_waiter"
18
27
  require_relative "cli/reconcile_notifier"
@@ -30,6 +39,7 @@ module Portage
30
39
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
31
40
  [--product-id ID] [--yes] [--dry-run]
32
41
  [--auto-open|--no-auto-open] [--notify-webhook URL]
42
+ [--handoff-target default|print|profile|agent:NAME]
33
43
  [--decision-backend jev|laya] [--min-confidence N] [--json]
34
44
  [--wait [--wait-timeout DURATION|off]]
35
45
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
@@ -51,9 +61,20 @@ module Portage
51
61
  [--velocity-count N --velocity-window-seconds N]
52
62
  [--allow HOST ...] [--clear-allowlist]
53
63
  portage orders reconcile [--checkout ID] [--json]
64
+ portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
65
+ portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
66
+ portage index show [--stores|--products] [--json]
67
+ portage index add <url> [--json]
68
+ portage index remove <host> [--json]
69
+ portage index sources [--json]
70
+ portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
71
+ [--history-days 90] [--include-product-pages] [--max-probes 200]
72
+ [--exclude HOST,HOST] [--dry-run] [--yes] [--json]
73
+ portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
74
+ [--url URL (open only)] [--json]
54
75
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
55
76
  portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
56
- portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
77
+ portage setup [--json] (interactive wizard on a TTY; --json/no TTY: today's doctor report)
57
78
  portage generate adapter NAME [--dir DIR]
58
79
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
59
80
  portage --version
@@ -66,8 +87,9 @@ module Portage
66
87
 
67
88
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
68
89
  "history" => :run_history, "payment" => :run_payment, "policy" => :run_policy,
69
- "orders" => :run_orders, "doctor" => :run_doctor, "configure" => :run_doctor,
70
- "setup" => :run_doctor, "generate" => :run_generate }.freeze
90
+ "orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
91
+ "doctor" => :run_doctor,
92
+ "configure" => :run_doctor, "setup" => :run_setup, "generate" => :run_generate }.freeze
71
93
 
72
94
  VERSION_FLAGS = %w[--version -v version].freeze
73
95
 
@@ -220,12 +242,27 @@ module Portage
220
242
  url = parsed[:buy][:url] || parsed[:store]
221
243
  parsed[:confidence_check] = confidence_check(parsed, url)
222
244
  return 1 unless parsed[:confidence_check]
245
+
246
+ parsed[:handoff_target] = handoff_target(parsed, url)
247
+ return 1 unless parsed[:handoff_target]
223
248
  return execute_buy(parsed, url) if url
224
249
 
225
250
  buy_from_search(parsed)
226
251
  end
227
252
  private_class_method :run_buy
228
253
 
254
+ # Built and validated up front, same posture as #confidence_check — an
255
+ # unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
256
+ # usage error, reported before a checkout is ever attempted rather than
257
+ # discovered mid hand-off (docs/plans/buy-skill-and-local-browser.md
258
+ # Phase 5).
259
+ def self.handoff_target(parsed, url)
260
+ HandoffTarget.new(override: parsed[:buy][:handoff_target])
261
+ rescue ArgumentError => e
262
+ invalid_buy_option(e.message, url: url, json: parsed[:json])
263
+ end
264
+ private_class_method :handoff_target
265
+
229
266
  # `portage buy` with no URL: search first, then buy from the store the
230
267
  # caller picks. `--yes` alone deliberately isn't enough to get here —
231
268
  # without a URL the merchant would have been chosen by a search ranker
@@ -260,8 +297,10 @@ module Portage
260
297
  private_class_method :prompt_for_offer
261
298
 
262
299
  def self.execute_buy(parsed, url, product_id: nil)
263
- options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
300
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
301
+ handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
264
302
  options[:product_id] ||= product_id
303
+ options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
265
304
  report = Buy.new(**options).call
266
305
  record_buy(report, options[:query])
267
306
  result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
@@ -270,6 +309,64 @@ module Portage
270
309
  end
271
310
  private_class_method :execute_buy
272
311
 
312
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
313
+ # profile` gives `portage buy` a browser of its own — the Portage
314
+ # profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
315
+ # the seam Buy#initialize's own doc comment already names as "the only
316
+ # option from the portage buy CLI" before this phase existed. Never
317
+ # raises: any failure to attach (portage-ucp-webmcp not installed, the
318
+ # profile not running, a bad target) just means Buy runs with no
319
+ # bridge at all — its dead-end hand-off to "profile" then reports that
320
+ # the browser isn't attached (see #dispatch_to_target's "profile"
321
+ # case) rather than this crashing the whole buy.
322
+ def self.profile_webmcp_bridge(url)
323
+ return nil unless Webmcp.available?
324
+
325
+ profile = BrowserProfile::Profile.new
326
+ return nil unless profile.status[:running]
327
+
328
+ full_url = absolute_url(url)
329
+ target = browser_profile_target(profile, full_url)
330
+ ws_url = target && target["webSocketDebuggerUrl"]
331
+ return nil unless ws_url
332
+
333
+ socket = BrowserProfile::CdpSocket.connect(ws_url)
334
+ allowlist = BrowserProfile::Allowlist.new(hosts: [URI(full_url).host])
335
+ BrowserProfile::Bridge.new(socket: socket, allowlist: allowlist)
336
+ rescue StandardError
337
+ nil
338
+ end
339
+ private_class_method :profile_webmcp_bridge
340
+
341
+ # Same "bare host gets an https:// prefix" normalization Buy#initialize
342
+ # applies to the same `url` — done again here since this runs before
343
+ # Buy exists to do it, and a bare host like "shop.example" isn't a
344
+ # URI CDP's own `/json/new` or Runtime.evaluate's `window.location`
345
+ # comparisons can parse a host out of otherwise.
346
+ def self.absolute_url(url)
347
+ raw = url.to_s.strip
348
+ raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}"
349
+ end
350
+ private_class_method :absolute_url
351
+
352
+ # An existing tab already on this store's host, so a shopper who's
353
+ # mid-session there isn't yanked to a fresh one; otherwise a brand new
354
+ # tab navigated straight to `url`.
355
+ def self.browser_profile_target(profile, url)
356
+ host = URI(url).host
357
+ existing = BrowserProfile::Cdp.list(port: profile.port)
358
+ .find { |t| t["type"] == "page" && same_host?(t["url"], host) }
359
+ existing || BrowserProfile::Cdp.new_tab(port: profile.port, url: url)
360
+ end
361
+ private_class_method :browser_profile_target
362
+
363
+ def self.same_host?(url, host)
364
+ URI(url.to_s).host == host
365
+ rescue URI::InvalidURIError
366
+ false
367
+ end
368
+ private_class_method :same_host?
369
+
273
370
  # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
274
371
  # no-op (returns nil) whenever there's nothing to wait on: --dry-run
275
372
  # never hands off at all, and a completed/browse-only/dead-end report
@@ -411,6 +508,7 @@ module Portage
411
508
  parser = buy_option_parser(buy, parsed)
412
509
  ProxySettings.add_options(parser, parsed[:proxy])
413
510
  parser.parse!(argv)
511
+ url = reinterpret_bare_query(url, buy, parsed)
414
512
  buy[:query] ||= ""
415
513
  return parsed if url || !buy[:query].strip.empty?
416
514
 
@@ -421,6 +519,24 @@ module Portage
421
519
  end
422
520
  private_class_method :parse_buy_options
423
521
 
522
+ # Bare arg is normally the store URL (`portage buy <url> --query "..."`),
523
+ # but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
524
+ # a URL/domain — means the same thing as `portage buy --query "coffee"`:
525
+ # search first, then buy from whatever the caller picks. Only reinterpret
526
+ # when no --query was already given, so `portage buy shop.com --query
527
+ # "coffee"` keeps treating "shop.com" as the store.
528
+ def self.reinterpret_bare_query(url, buy, parsed)
529
+ return url unless url && buy[:query].to_s.strip.empty? && !url_like?(url)
530
+
531
+ buy[:url] = nil
532
+ parsed[:find][:query] = buy[:query] = url
533
+ nil
534
+ end
535
+ private_class_method :reinterpret_bare_query
536
+
537
+ def self.url_like?(text) = text.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || text.include?(".")
538
+ private_class_method :url_like?
539
+
424
540
  def self.buy_option_parser(buy, parsed)
425
541
  OptionParser.new do |parser|
426
542
  parser.on("--qty N", Integer) { |v| buy[:qty] = v }
@@ -428,6 +544,7 @@ module Portage
428
544
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
429
545
  parser.on("--yes") { buy[:yes] = true }
430
546
  parser.on("--dry-run") { buy[:dry_run] = true }
547
+ parser.on("--autofill") { buy[:autofill] = true }
431
548
  parser.on("--json") { parsed[:json] = true }
432
549
  add_handoff_options(parser, buy)
433
550
  add_wait_options(parser, parsed)
@@ -448,6 +565,7 @@ module Portage
448
565
  def self.add_handoff_options(parser, buy)
449
566
  parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
450
567
  parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
568
+ parser.on("--handoff-target TARGET") { |v| buy[:handoff_target] = v }
451
569
  end
452
570
  private_class_method :add_handoff_options
453
571
 
@@ -757,6 +875,9 @@ module Portage
757
875
  when "pending"
758
876
  "Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
759
877
  "`portage payment enroll` again."
878
+ when "handoff_only"
879
+ "#{result[:host]} is hand-off only — there's nothing to set up here. Sign in and add a card " \
880
+ "on #{result[:host]} yourself."
760
881
  else "This store doesn't support payment enrollment."
761
882
  end
762
883
  end
@@ -833,6 +954,379 @@ module Portage
833
954
  end
834
955
  private_class_method :format_reconcile_result
835
956
 
957
+ # --- index (docs/plans/buy-skill-and-local-browser.md Phase 2b) ---
958
+
959
+ INDEX_SUBCOMMANDS = {
960
+ "build" => ->(argv) { run_index_build(argv, refresh: false) },
961
+ "refresh" => ->(argv) { run_index_build(argv, refresh: true) },
962
+ "show" => ->(argv) { run_index_show(argv) },
963
+ "add" => ->(argv) { run_index_add(argv) },
964
+ "remove" => ->(argv) { run_index_remove(argv) },
965
+ "sources" => ->(argv) { run_index_sources(argv) }
966
+ }.freeze
967
+
968
+ def self.run_index(argv)
969
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
970
+ return INDEX_SUBCOMMANDS[sub].call(argv) if INDEX_SUBCOMMANDS.key?(sub)
971
+
972
+ warn USAGE
973
+ 1
974
+ end
975
+ private_class_method :run_index
976
+
977
+ def self.parse_index_build_options(argv)
978
+ opts = { sources: nil, queries: nil, dry_run: false, json: false, export: nil }
979
+ OptionParser.new do |parser|
980
+ parser.on("--sources LIST") { |v| opts[:sources] = v.split(",").map(&:strip) }
981
+ parser.on("--queries FILE") { |v| opts[:queries] = v }
982
+ parser.on("--dry-run") { opts[:dry_run] = true }
983
+ parser.on("--export DIR") { |v| opts[:export] = v }
984
+ parser.on("--json") { opts[:json] = true }
985
+ end.parse!(argv)
986
+ opts[:queries] &&= File.readlines(opts[:queries]).map(&:strip).reject(&:empty?)
987
+ opts
988
+ end
989
+ private_class_method :parse_index_build_options
990
+
991
+ def self.run_index_build(argv, refresh:)
992
+ opts = parse_index_build_options(argv)
993
+ builder = Index::Builder.new(sources: index_sources(opts[:sources]), out: opts[:json] ? nil : $stdout)
994
+ result = if refresh
995
+ builder.refresh(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
996
+ else
997
+ builder.build(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
998
+ end
999
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_build(result)
1000
+ 0
1001
+ end
1002
+ private_class_method :run_index_build
1003
+
1004
+ def self.index_sources(names) = names ? Index::Sources.by_name(names) : nil
1005
+ private_class_method :index_sources
1006
+
1007
+ def self.format_index_build(result)
1008
+ lines = ["Ran #{result[:sources_run].join(', ')} — #{result[:candidates]} candidate(s)."]
1009
+ lines << "Checked #{result[:new_origins_checked].length} new origin(s), " \
1010
+ "#{result[:verified].length} verified UCP."
1011
+ lines << "Hit the #{Index::Builder::MAX_NEW_PROBES}-probe cap for this run." if result[:capped]
1012
+ lines << "#{result[:products_added]} product sighting(s) recorded." if result[:products_added]
1013
+ if result[:exported]
1014
+ lines << "Exported #{result[:exported][:stores]} store(s), #{result[:exported][:products]} " \
1015
+ "product(s) to #{result[:exported][:dir]}."
1016
+ end
1017
+ lines.join("\n")
1018
+ end
1019
+ private_class_method :format_index_build
1020
+
1021
+ def self.run_index_show(argv)
1022
+ opts = { kind: nil, json: false }
1023
+ OptionParser.new do |parser|
1024
+ parser.on("--stores") { opts[:kind] = "stores" }
1025
+ parser.on("--products") { opts[:kind] = "products" }
1026
+ parser.on("--json") { opts[:json] = true }
1027
+ end.parse!(argv)
1028
+
1029
+ result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
1030
+ products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
1031
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
1032
+ 0
1033
+ end
1034
+ private_class_method :run_index_show
1035
+
1036
+ def self.format_index_show(result)
1037
+ lines = ["Stores:"]
1038
+ result[:stores].each { |s| lines << " #{s['origin']} (#{Array(s['sources']).join(', ')})" }
1039
+ lines << "(none)" if result[:stores].empty?
1040
+ lines << "Products:"
1041
+ result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
1042
+ lines << "(none)" if result[:products].empty?
1043
+ lines.join("\n")
1044
+ end
1045
+ private_class_method :format_index_show
1046
+
1047
+ def self.run_index_add(argv)
1048
+ json = argv.delete("--json") ? true : false
1049
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1050
+ unless url
1051
+ warn USAGE
1052
+ return 1
1053
+ end
1054
+
1055
+ result = Index::Builder.new.add(url)
1056
+ puts json ? JSON.pretty_generate(result) : result[:message]
1057
+ result[:added] ? 0 : 1
1058
+ end
1059
+ private_class_method :run_index_add
1060
+
1061
+ def self.run_index_remove(argv)
1062
+ json = argv.delete("--json") ? true : false
1063
+ host = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1064
+ unless host
1065
+ warn USAGE
1066
+ return 1
1067
+ end
1068
+
1069
+ result = Index::Builder.new.remove(host)
1070
+ puts json ? JSON.pretty_generate(result) : result[:message]
1071
+ result[:removed] ? 0 : 1
1072
+ end
1073
+ private_class_method :run_index_remove
1074
+
1075
+ def self.run_index_sources(argv)
1076
+ json = argv.delete("--json") ? true : false
1077
+ sources = Index::Sources.all.map { |s| { name: s.name, description: s.description, path: s.source_path } }
1078
+ puts json ? JSON.pretty_generate(sources) : format_index_sources(sources)
1079
+ 0
1080
+ end
1081
+ private_class_method :run_index_sources
1082
+
1083
+ def self.format_index_sources(sources)
1084
+ sources.map { |s| "#{s[:name]}: #{s[:description]}#{" (#{s[:path]})" if s[:path]}" }.join("\n")
1085
+ end
1086
+ private_class_method :format_index_sources
1087
+
1088
+ # --- browser (docs/plans/buy-skill-and-local-browser.md Phase 3) ---
1089
+
1090
+ BROWSER_SUBCOMMANDS = { "import" => ->(argv) { run_browser_import(argv) },
1091
+ "profile" => ->(argv) { run_browser_profile(argv) } }.freeze
1092
+
1093
+ def self.run_browser(argv)
1094
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1095
+ return BROWSER_SUBCOMMANDS[sub].call(argv) if BROWSER_SUBCOMMANDS.key?(sub)
1096
+
1097
+ warn USAGE
1098
+ 1
1099
+ end
1100
+ private_class_method :run_browser
1101
+
1102
+ def self.parse_browser_import_options(argv)
1103
+ opts = { browser: nil, root: nil, history_days: BrowserImport::Importer::DEFAULT_HISTORY_DAYS,
1104
+ include_product_pages: false, max_probes: BrowserImport::Importer::MAX_PROBES, exclude: [],
1105
+ dry_run: false, yes: false, json: false }
1106
+ browser_import_option_parser(opts).parse!(argv)
1107
+ opts
1108
+ end
1109
+ private_class_method :parse_browser_import_options
1110
+
1111
+ def self.browser_import_option_parser(opts)
1112
+ OptionParser.new do |parser|
1113
+ parser.on("--browser NAME", BrowserImport::Profiles::BROWSERS) { |v| opts[:browser] = v }
1114
+ parser.on("--profile-root DIR") { |v| opts[:root] = File.expand_path(v) }
1115
+ parser.on("--history-days N", Integer) { |v| opts[:history_days] = v }
1116
+ parser.on("--max-probes N", Integer) { |v| opts[:max_probes] = v }
1117
+ parser.on("--exclude HOSTS", Array) { |v| opts[:exclude] = v.map(&:strip) }
1118
+ %i[include_product_pages dry_run yes json].each do |flag|
1119
+ parser.on("--#{flag.to_s.tr('_', '-')}") { opts[flag] = true }
1120
+ end
1121
+ end
1122
+ end
1123
+ private_class_method :browser_import_option_parser
1124
+
1125
+ # Reads, reduces and probes (BrowserImport::Importer#plan), shows the
1126
+ # list, and saves only through BrowserImport::Confirm's gate: `--yes`,
1127
+ # or a "y" at a real TTY prompt. Under `--json`/no TTY without `--yes`
1128
+ # nothing is written — the report says `saved: false,
1129
+ # needs_confirmation: true` and exits 0, so an agent shows the user the
1130
+ # list and re-runs with `--yes` only once they've approved it.
1131
+ def self.run_browser_import(argv)
1132
+ opts = parse_browser_import_options(argv)
1133
+ plan = browser_importer.plan(browser_import_options(opts))
1134
+ return report_browser_import(plan, opts, nil) if plan[:error]
1135
+
1136
+ puts format_browser_import(plan) unless opts[:json]
1137
+ interactive = !opts[:json] && $stdin.tty?
1138
+ decision = BrowserImport::Confirm.new(interactive: interactive).call(plan, yes: opts[:yes],
1139
+ dry_run: opts[:dry_run])
1140
+ saved = decision == :save ? browser_importer.save(plan) : nil
1141
+ report_browser_import(plan, opts, decision, saved)
1142
+ rescue OptionParser::ParseError => e
1143
+ warn "#{e.message}\n#{USAGE}"
1144
+ 1
1145
+ end
1146
+ private_class_method :run_browser_import
1147
+
1148
+ # The real HandoffOnly list (docs/plans/buy-skill-and-local-browser.md
1149
+ # Phase 5) into Importer's own injectable `handoff_only_hosts:` seam
1150
+ # (Phase 3 left it defaulting to `[]` for exactly this) — a history/
1151
+ # bookmark domain on the list is kept as `handoff_only: true` and never
1152
+ # probed.
1153
+ def self.browser_importer
1154
+ BrowserImport::Importer.new(handoff_only_hosts: HandoffOnly.new.hosts)
1155
+ end
1156
+ private_class_method :browser_importer
1157
+
1158
+ def self.browser_import_options(opts)
1159
+ browser = opts[:browser] || BrowserImport::Profiles.detect || "chrome"
1160
+ BrowserImport::Importer::Options.new(
1161
+ browser: browser, root: opts[:root] || BrowserImport::Profiles.default_root(browser),
1162
+ history_days: opts[:history_days], include_product_pages: opts[:include_product_pages],
1163
+ max_probes: opts[:max_probes], exclude: opts[:exclude]
1164
+ )
1165
+ end
1166
+ private_class_method :browser_import_options
1167
+
1168
+ BROWSER_IMPORT_MESSAGES = {
1169
+ dry_run: "Dry run — nothing saved.",
1170
+ nothing: "No shop domains to save.",
1171
+ declined: "Nothing saved.",
1172
+ needs_confirmation: "Nothing saved: there's no terminal to confirm on. Show this list to the user, then " \
1173
+ "re-run with --yes once they've approved it (--exclude HOST,... drops any they don't want)."
1174
+ }.freeze
1175
+
1176
+ def self.report_browser_import(plan, opts, decision, saved = nil)
1177
+ message = plan[:message] || browser_import_message(decision, saved)
1178
+ if opts[:json]
1179
+ puts JSON.pretty_generate(plan.merge(saved: !saved.nil?, needs_confirmation: decision == :needs_confirmation,
1180
+ message: message))
1181
+ else
1182
+ puts message
1183
+ end
1184
+ plan[:error] ? 1 : 0
1185
+ end
1186
+ private_class_method :report_browser_import
1187
+
1188
+ def self.browser_import_message(decision, saved)
1189
+ return "Saved #{saved[:stores]} store(s) and #{saved[:products]} product(s) to your local index." if saved
1190
+
1191
+ BROWSER_IMPORT_MESSAGES.fetch(decision, "Nothing saved.")
1192
+ end
1193
+ private_class_method :browser_import_message
1194
+
1195
+ def self.format_browser_import(plan)
1196
+ lines = browser_import_counts(plan)
1197
+ lines << "Shops found (#{plan[:kept].length}):"
1198
+ plan[:kept].each { |entry| lines << " #{browser_import_line(entry)}" }
1199
+ lines << " (none)" if plan[:kept].empty?
1200
+ lines << "#{plan[:products].length} product page(s) to keep." if plan[:products].any?
1201
+ lines.join("\n")
1202
+ end
1203
+ private_class_method :format_browser_import
1204
+
1205
+ def self.browser_import_counts(plan)
1206
+ skipped = plan[:skipped].map { |reason, n| "#{n} #{reason}" }.join(", ")
1207
+ lines = ["Read #{plan[:rows][:history]} history and #{plan[:rows][:bookmark]} bookmark row(s) across " \
1208
+ "#{plan[:domains]} domain(s) from #{plan[:browser]}.",
1209
+ "Skipped #{skipped.empty? ? 'none' : skipped}; probed #{plan[:probed]} " \
1210
+ "(#{plan[:not_ucp]} without UCP, #{plan[:cached_miss]} already known not to)."]
1211
+ lines << "Hit the #{plan[:probed]}-probe cap; #{plan[:unprobed]} domain(s) left unprobed." if plan[:capped]
1212
+ lines
1213
+ end
1214
+ private_class_method :browser_import_counts
1215
+
1216
+ def self.browser_import_line(entry)
1217
+ categories = entry[:category_names].empty? ? "uncategorised" : entry[:category_names].join(", ")
1218
+ "#{entry[:domain]} — #{entry[:verdict]} — #{categories} " \
1219
+ "(#{entry[:sources].join(', ')}, #{entry[:visits]} visit(s))"
1220
+ end
1221
+ private_class_method :browser_import_line
1222
+
1223
+ # --- browser profile (docs/plans/buy-skill-and-local-browser.md Phase 6) ---
1224
+
1225
+ BROWSER_PROFILE_SUBCOMMANDS = {
1226
+ "init" => ->(argv) { run_browser_profile_init(argv) },
1227
+ "open" => ->(argv) { run_browser_profile_open(argv) },
1228
+ "status" => ->(argv) { run_browser_profile_status(argv) }
1229
+ }.freeze
1230
+
1231
+ def self.run_browser_profile(argv)
1232
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1233
+ return browser_profile_usage unless BROWSER_PROFILE_SUBCOMMANDS.key?(sub)
1234
+
1235
+ BROWSER_PROFILE_SUBCOMMANDS[sub].call(argv)
1236
+ end
1237
+ private_class_method :run_browser_profile
1238
+
1239
+ def self.browser_profile_usage
1240
+ warn USAGE
1241
+ 1
1242
+ end
1243
+ private_class_method :browser_profile_usage
1244
+
1245
+ def self.parse_browser_profile_options(argv)
1246
+ opts = { browser: nil, port: BrowserProfile::Profile::DEFAULT_PORT, url: nil, json: false }
1247
+ OptionParser.new do |parser|
1248
+ parser.on("--browser NAME", BrowserProfile::Browsers::CHROMIUM) { |v| opts[:browser] = v }
1249
+ parser.on("--port N", Integer) { |v| opts[:port] = v }
1250
+ parser.on("--url URL") { |v| opts[:url] = v }
1251
+ parser.on("--json") { opts[:json] = true }
1252
+ end.parse!(argv)
1253
+ opts
1254
+ end
1255
+ private_class_method :parse_browser_profile_options
1256
+
1257
+ # `--browser` names the exact browser; without it, the first Chromium
1258
+ # family browser BrowserImport::Profiles finds installed, falling back
1259
+ # to "chrome" — same "pick something reasonable, let --browser
1260
+ # override" posture as run_browser_import's own default.
1261
+ def self.browser_profile_for(opts)
1262
+ browser = opts[:browser] || BrowserProfile::Browsers.detect || "chrome"
1263
+ BrowserProfile::Profile.new(browser: browser, port: opts[:port])
1264
+ end
1265
+ private_class_method :browser_profile_for
1266
+
1267
+ def self.run_browser_profile_init(argv)
1268
+ opts = parse_browser_profile_options(argv)
1269
+ result = browser_profile_for(opts).init!
1270
+ puts opts[:json] ? JSON.pretty_generate(result) : "Profile ready at #{result[:dir]} (#{result[:browser]})."
1271
+ 0
1272
+ rescue OptionParser::ParseError => e
1273
+ warn "#{e.message}\n#{USAGE}"
1274
+ 1
1275
+ end
1276
+ private_class_method :run_browser_profile_init
1277
+
1278
+ # Launches the profile (if it isn't already running on its own port)
1279
+ # and either opens a new tab at --url or attaches to the first
1280
+ # existing one. Never touches the browser's default profile — Profile
1281
+ # itself only ever points --user-data-dir at its own dedicated
1282
+ # directory.
1283
+ def self.run_browser_profile_open(argv)
1284
+ opts = parse_browser_profile_options(argv)
1285
+ result = browser_profile_for(opts).open!(url: opts[:url])
1286
+ puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_open(result)
1287
+ 0
1288
+ rescue BrowserProfile::Error => e
1289
+ report_browser_profile_error(e, opts[:json])
1290
+ rescue OptionParser::ParseError => e
1291
+ warn "#{e.message}\n#{USAGE}"
1292
+ 1
1293
+ end
1294
+ private_class_method :run_browser_profile_open
1295
+
1296
+ def self.run_browser_profile_status(argv)
1297
+ opts = parse_browser_profile_options(argv)
1298
+ result = browser_profile_for(opts).status
1299
+ puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_status(result)
1300
+ 0
1301
+ rescue OptionParser::ParseError => e
1302
+ warn "#{e.message}\n#{USAGE}"
1303
+ 1
1304
+ end
1305
+ private_class_method :run_browser_profile_status
1306
+
1307
+ def self.format_browser_profile_open(result)
1308
+ tab = result.dig(:target, "url")
1309
+ "#{result[:browser]} profile is open (port #{result[:port]}, #{result[:dir]})#{" — tab: #{tab}" if tab}."
1310
+ end
1311
+ private_class_method :format_browser_profile_open
1312
+
1313
+ def self.format_browser_profile_status(result)
1314
+ return "#{result[:browser]} profile (#{result[:dir]}) isn't running." unless result[:running]
1315
+
1316
+ "#{result[:browser]} profile is running on port #{result[:port]} (#{result[:dir]})."
1317
+ end
1318
+ private_class_method :format_browser_profile_status
1319
+
1320
+ def self.report_browser_profile_error(error, json)
1321
+ if json
1322
+ puts JSON.pretty_generate(error: error.class.name.split("::").last, message: error.message)
1323
+ else
1324
+ warn error.message
1325
+ end
1326
+ 1
1327
+ end
1328
+ private_class_method :report_browser_profile_error
1329
+
836
1330
  # --- doctor ---
837
1331
 
838
1332
  def self.parse_doctor_options(argv)
@@ -847,17 +1341,45 @@ module Portage
847
1341
  end
848
1342
  private_class_method :parse_doctor_options
849
1343
 
850
- def self.run_doctor(argv)
1344
+ # `wizard: :force` is `portage setup`, always offering the wizard on a
1345
+ # TTY; `:auto` is `doctor`/`configure`, which only offers it when
1346
+ # Doctor#nothing_configured? — the read-only report is what every other
1347
+ # run of `doctor` still gets, exactly as before this phase
1348
+ # (docs/plans/buy-skill-and-local-browser.md Phase 4).
1349
+ def self.run_doctor(argv, wizard: :auto)
851
1350
  opts = parse_doctor_options(argv)
852
1351
  require File.expand_path(opts[:require]) if opts[:require]
853
1352
  adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
854
1353
  proxy_settings = apply_proxy_settings(opts[:proxy])
855
1354
  return 1 unless proxy_settings
856
1355
 
857
- report_doctor(Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings).call, json: opts[:json])
1356
+ doctor = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings,
1357
+ seller: !(opts[:require] || opts[:adapter]).nil?)
1358
+ return run_setup_wizard if run_wizard?(wizard, opts, doctor)
1359
+
1360
+ report_doctor(doctor.call, json: opts[:json])
858
1361
  end
859
1362
  private_class_method :run_doctor
860
1363
 
1364
+ def self.run_setup(argv) = run_doctor(argv, wizard: :force)
1365
+ private_class_method :run_setup
1366
+
1367
+ # --json or no TTY on stdin always stays today's read-only report,
1368
+ # whichever command name was used — a piped/CI/agent run never blocks
1369
+ # on a prompt it can't answer.
1370
+ def self.run_wizard?(mode, opts, doctor)
1371
+ return false if opts[:json] || !$stdin.tty?
1372
+ return true if mode == :force
1373
+
1374
+ doctor.nothing_configured?
1375
+ end
1376
+ private_class_method :run_wizard?
1377
+
1378
+ def self.run_setup_wizard
1379
+ SetupWizard.new.call
1380
+ end
1381
+ private_class_method :run_setup_wizard
1382
+
861
1383
  def self.report_doctor(findings, json:)
862
1384
  puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
863
1385
  findings.none?(&:warning?) ? 0 : 1