portage-cli 0.7.5 → 0.9.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 (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -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_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. metadata +67 -2
data/lib/portage/cli.rb CHANGED
@@ -1,18 +1,35 @@
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"
19
+ require_relative "cli/quotes"
20
+ require_relative "cli/money"
21
+ require_relative "cli/human_prompt"
22
+ require_relative "cli/product_page"
23
+ require_relative "cli/approval_policy"
24
+ require_relative "cli/offer_choice"
25
+ require_relative "cli/pick"
26
+ require_relative "cli/approve"
13
27
  require_relative "cli/payment_methods"
14
28
  require_relative "cli/proxy_settings"
29
+ require_relative "cli/handoff_only"
30
+ require_relative "cli/handoff_target"
15
31
  require_relative "cli/doctor"
32
+ require_relative "cli/setup_wizard"
16
33
  require_relative "cli/handoff_reconciler"
17
34
  require_relative "cli/handoff_waiter"
18
35
  require_relative "cli/reconcile_notifier"
@@ -30,12 +47,18 @@ module Portage
30
47
  usage: portage buy <url> --query "..." [--qty N] [--payment-token TOKEN]
31
48
  [--product-id ID] [--yes] [--dry-run]
32
49
  [--auto-open|--no-auto-open] [--notify-webhook URL]
50
+ [--handoff-target default|print|profile|agent:NAME]
33
51
  [--decision-backend jev|laya] [--min-confidence N] [--json]
34
52
  [--wait [--wait-timeout DURATION|off]]
53
+ portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
54
+ portage buy --quote QUOTE_ID --yes [--json] ...
35
55
  portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
36
56
  portage find --query "..." [--max-price N] [--limit N] [--json]
37
57
  portage compare <url> --product-id ID [--id VALUE ...] [--results N]
38
58
  [--max-price N] [--json]
59
+ portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
60
+ [--choose REF | --compare REF | --view REF]
61
+ portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
39
62
  portage history [list] [--purchases|--searches] [--limit N] [--json]
40
63
  portage history clear [--purchases|--searches]
41
64
  portage payment list [--json]
@@ -50,10 +73,22 @@ module Portage
50
73
  [--rolling-cap N --rolling-window-seconds N --currency CUR]
51
74
  [--velocity-count N --velocity-window-seconds N]
52
75
  [--allow HOST ...] [--clear-allowlist]
76
+ [--require-approval person|any|off] (lowering asks at a terminal)
53
77
  portage orders reconcile [--checkout ID] [--json]
78
+ portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
79
+ 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 remove <host> [--json]
83
+ portage index sources [--json]
84
+ portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
85
+ [--history-days 90] [--include-product-pages] [--max-probes 200]
86
+ [--exclude HOST,HOST] [--dry-run] [--yes] [--json]
87
+ portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
88
+ [--url URL (open only)] [--json]
54
89
  portage doctor [--require FILE] [--adapter CLASS_NAME] [--json]
55
90
  portage configure [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
56
- portage setup [--require FILE] [--adapter CLASS_NAME] [--json] (alias for doctor)
91
+ portage setup [--json] (interactive wizard on a TTY; --json/no TTY: today's doctor report)
57
92
  portage generate adapter NAME [--dir DIR]
58
93
  portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
59
94
  portage --version
@@ -65,9 +100,11 @@ module Portage
65
100
  USAGE
66
101
 
67
102
  COMMANDS = { "buy" => :run_buy, "find" => :run_find, "compare" => :run_compare,
103
+ "pick" => :run_pick, "approve" => :run_approve,
68
104
  "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
105
+ "orders" => :run_orders, "index" => :run_index, "browser" => :run_browser,
106
+ "doctor" => :run_doctor,
107
+ "configure" => :run_doctor, "setup" => :run_setup, "generate" => :run_generate }.freeze
71
108
 
72
109
  VERSION_FLAGS = %w[--version -v version].freeze
73
110
 
@@ -120,18 +157,26 @@ module Portage
120
157
  return 1 unless apply_proxy_settings(options.delete(:proxy))
121
158
 
122
159
  report = Find.new(**options).call
123
- record_find(report)
160
+ report = with_search_id(report, record_find(report))
124
161
  puts json ? JSON.pretty_generate(report) : format_find(report)
125
162
  report[:offers].any? ? 0 : 1
126
163
  end
127
164
  private_class_method :run_find
128
165
 
166
+ # @return [Hash, nil] the saved search entry.
129
167
  def self.record_find(report)
130
168
  History.new.record_search(query: report[:query], offer_count: report[:offers].length,
131
- message: report[:message])
169
+ message: report[:message], offers: report[:offers])
132
170
  end
133
171
  private_class_method :record_find
134
172
 
173
+ # docs/plans/human-pick-and-approve.md Phase 2: the saved search's id,
174
+ # for `portage pick --search`. Additive; absent when nothing was saved.
175
+ def self.with_search_id(report, entry)
176
+ entry.is_a?(Hash) && entry["search_id"] ? report.merge(search_id: entry["search_id"]) : report
177
+ end
178
+ private_class_method :with_search_id
179
+
135
180
  def self.parse_find_options(argv)
136
181
  opts = {}
137
182
  find_option_parser(opts).parse!(argv)
@@ -173,16 +218,26 @@ module Portage
173
218
  return 1 unless apply_proxy_settings(options.delete(:proxy))
174
219
 
175
220
  report = Compare.new(origin_url: url, **options).call
176
- # Recorded as a search, not a purchase — compare never checks out. The
177
- # query string names the compare so `portage history list` doesn't
178
- # read it as a plain text search for the origin product's own title.
179
- History.new.record_search(query: "compare: #{url} (product #{options[:origin_product_id]})",
180
- offer_count: report[:offers].length, message: report[:message])
221
+ report = with_search_id(report, record_compare(url, options[:origin_product_id], report))
181
222
  puts json ? JSON.pretty_generate(report) : format_compare(report)
182
223
  report[:offers].any? ? 0 : 1
183
224
  end
184
225
  private_class_method :run_compare
185
226
 
227
+ # Recorded as a search, not a purchase — compare never checks out. The
228
+ # query string names the compare so `portage history list` doesn't
229
+ # read it as a plain text search for the origin product's own title.
230
+ # Its offers are kept with their refs, like `find`'s
231
+ # (docs/plans/human-pick-and-approve.md Phase 2), each carrying the
232
+ # catalog query compare searched with (the origin product's title), so
233
+ # `buy --offer REF` searches for the product, not for "compare: ...".
234
+ def self.record_compare(url, product_id, report)
235
+ offers = report[:offers].map { |offer| offer.merge(query: report[:query]) }
236
+ History.new.record_search(query: "compare: #{url} (product #{product_id})", offer_count: offers.length,
237
+ message: report[:message], offers: offers)
238
+ end
239
+ private_class_method :record_compare
240
+
186
241
  def self.parse_compare_options(argv)
187
242
  url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
188
243
  opts = { identity: [] }
@@ -220,56 +275,298 @@ module Portage
220
275
  url = parsed[:buy][:url] || parsed[:store]
221
276
  parsed[:confidence_check] = confidence_check(parsed, url)
222
277
  return 1 unless parsed[:confidence_check]
278
+
279
+ parsed[:handoff_target] = handoff_target(parsed, url)
280
+ return 1 unless parsed[:handoff_target]
281
+
282
+ dispatch_buy(parsed, url)
283
+ end
284
+ private_class_method :run_buy
285
+
286
+ # A saved quote or offer names its own store; otherwise a url does, and
287
+ # with neither the search picks one.
288
+ def self.dispatch_buy(parsed, url)
289
+ return buy_from_quote(parsed) if parsed[:quote]
290
+ return buy_from_offer(parsed) if parsed[:offer]
223
291
  return execute_buy(parsed, url) if url
224
292
 
225
293
  buy_from_search(parsed)
226
294
  end
227
- private_class_method :run_buy
295
+ private_class_method :dispatch_buy
296
+
297
+ # Built and validated up front, same posture as #confidence_check — an
298
+ # unknown --handoff-target/PORTAGE_HANDOFF_TARGET/config.json value is a
299
+ # usage error, reported before a checkout is ever attempted rather than
300
+ # discovered mid hand-off (docs/plans/buy-skill-and-local-browser.md
301
+ # Phase 5).
302
+ def self.handoff_target(parsed, url)
303
+ HandoffTarget.new(override: parsed[:buy][:handoff_target])
304
+ rescue ArgumentError => e
305
+ invalid_buy_option(e.message, url: url, json: parsed[:json])
306
+ end
307
+ private_class_method :handoff_target
228
308
 
229
309
  # `portage buy` with no URL: search first, then buy from the store the
230
310
  # caller picks. `--yes` alone deliberately isn't enough to get here —
231
311
  # without a URL the merchant would have been chosen by a search ranker
232
312
  # rather than by a person, so either `--store` (handled above) or an
233
- # interactive pick has to name it. Piped/CI runs list the offers and stop.
313
+ # interactive pick has to name it. Runs with no terminal (or under
314
+ # --json) list the offers and stop.
234
315
  def self.buy_from_search(parsed)
235
316
  report = Find.new(**parsed[:find]).call
236
- record_find(report)
317
+ report = with_search_id(report, record_find(report))
237
318
  offer = pick_offer(report, parsed[:json])
238
319
  return report[:offers].any? ? 0 : 1 unless offer
239
320
 
321
+ parsed[:offer_ref] = offer[:offer_ref]
322
+ parsed[:page] = { title: offer[:title], url: offer[:url] }
240
323
  execute_buy(parsed, offer[:store], product_id: offer[:product_id])
241
324
  end
242
325
  private_class_method :buy_from_search
243
326
 
327
+ # `--offer REF`: the store, product and query come from the saved
328
+ # `find` that produced the ref, as if they'd been passed as flags.
329
+ def self.buy_from_offer(parsed)
330
+ offer = History.new.offer(parsed[:offer])
331
+ unless offer
332
+ invalid_buy_option("No saved offer #{parsed[:offer]} — run `portage find` again.",
333
+ url: nil, json: parsed[:json], outcome: "offer_not_found")
334
+ return 1
335
+ end
336
+
337
+ parsed[:offer_ref] = parsed[:offer]
338
+ parsed[:page] = { title: offer["title"], url: offer["url"] }
339
+ parsed[:buy][:query] = offer["query"].to_s
340
+ execute_buy(parsed, offer["store"], product_id: offer["product_id"])
341
+ end
342
+ private_class_method :buy_from_offer
343
+
344
+ # `--quote QUOTE_ID`: buys what a `--dry-run` showed. Buy is handed the
345
+ # quoted total as a cap and refuses (`quote_changed`) if the real
346
+ # checkout costs more, so the person's approval always covers the total
347
+ # that gets charged. The quote is spent by #settle_quote, once the run
348
+ # purchases or hands off.
349
+ #
350
+ # docs/plans/human-pick-and-approve.md Phase 2: a real run (`--yes`, not
351
+ # `--dry-run`) of a quote that isn't approved enough for
352
+ # `--require-approval` is refused with `needs_approval` before Buy
353
+ # runs, so it never charges and never hands off.
354
+ def self.buy_from_quote(parsed)
355
+ quote = usable_quote(parsed)
356
+ return 1 unless quote
357
+
358
+ level = ApprovalPolicy.level
359
+ if real_run?(parsed) && !ApprovalPolicy.satisfied?(quote, level)
360
+ return refuse_unapproved_quote(parsed, quote, level)
361
+ end
362
+
363
+ parsed[:quote_record] = quote
364
+ buy = parsed[:buy]
365
+ buy.merge!(qty: quote["qty"], product_id: quote["product_id"], query: quote["query"].to_s)
366
+ buy.merge!(quote_total: quote["total"], quote_currency: quote["currency"])
367
+ execute_buy(parsed, quote["store"])
368
+ end
369
+ private_class_method :buy_from_quote
370
+
371
+ def self.usable_quote(parsed)
372
+ quote = Quotes.new.find(parsed[:quote])
373
+ unless quote
374
+ return invalid_buy_option("No saved quote #{parsed[:quote]} — run `portage buy ... --dry-run --json` " \
375
+ "for a new one.", url: nil, json: parsed[:json], outcome: "quote_not_found")
376
+ end
377
+ return quote unless quote["used_at"]
378
+
379
+ invalid_buy_option("Quote #{parsed[:quote]} has already been used — dry-run again for a new one.",
380
+ url: quote["store"], json: parsed[:json], outcome: "quote_used")
381
+ end
382
+ private_class_method :usable_quote
383
+
384
+ def self.refuse_unapproved_quote(parsed, quote, level)
385
+ summary = Approve.summary(quote)
386
+ report = { url: quote["store"], checkout_url: nil, products: [], warnings: [], source: "none", browse: false,
387
+ checkout: false }.merge(Approve.needs_approval(summary, message: approval_message(summary, level)))
388
+ print_buy_report(report, nil, parsed[:json])
389
+ buy_exit_code(report)
390
+ end
391
+ private_class_method :refuse_unapproved_quote
392
+
393
+ # With a terminal (and no --json) the person picks there, through the
394
+ # same HumanPrompt numbered pick `portage pick` uses; otherwise the
395
+ # offers are printed and nothing is bought.
244
396
  def self.pick_offer(report, json)
245
- output = json ? JSON.pretty_generate(report) : format_find(report)
246
- puts output
247
- return nil unless $stdin.tty? && report[:offers].any?
397
+ prompt = HumanPrompt.new(json: json)
398
+ unless prompt.tty? && report[:offers].any?
399
+ puts json ? JSON.pretty_generate(report) : format_find(report)
400
+ return nil
401
+ end
248
402
 
249
- prompt_for_offer(report[:offers])
403
+ prompt_for_offer(prompt, report)
250
404
  end
251
405
  private_class_method :pick_offer
252
406
 
253
- def self.prompt_for_offer(offers)
254
- print "\nPick 1-#{offers.length} to buy (Enter to quit): "
255
- choice = $stdin.gets.to_s.strip
256
- return nil unless choice.match?(/\A\d+\z/)
257
-
258
- offers[choice.to_i - 1] if choice.to_i.between?(1, offers.length)
407
+ def self.prompt_for_offer(prompt, report)
408
+ prompt.say(report[:message].to_s)
409
+ choices = report[:offers].map { |offer| OfferChoice.for(offer) }
410
+ index = prompt.choose("Pick one to buy", choices, view: OfferChoice.method(:view_message))
411
+ index && report[:offers][index]
259
412
  end
260
413
  private_class_method :prompt_for_offer
261
414
 
415
+ # docs/plans/human-pick-and-approve.md Phase 2: under
416
+ # `--require-approval any|person` a real `--yes` run with no approved
417
+ # `--quote` is run as a dry run instead — never charged, never handed
418
+ # off — which prices it and saves a quote, and the report becomes
419
+ # `needs_approval` naming that quote and the next steps. Done here, not
420
+ # in Buy, so Buy's library callers aren't governed by the CLI policy.
262
421
  def self.execute_buy(parsed, url, product_id: nil)
263
- options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check])
264
- options[:product_id] ||= product_id
422
+ gated = approval_gate?(parsed)
423
+ parsed[:buy] = parsed[:buy].merge(yes: false, dry_run: true) if gated
424
+ options = buy_options(parsed, url, product_id: product_id)
265
425
  report = Buy.new(**options).call
266
426
  record_buy(report, options[:query])
427
+ report = settle_quote(report, parsed, options)
428
+ report = needs_approval_report(report) if gated
267
429
  result = parsed[:wait] ? wait_for_handoff(report, parsed) : nil
268
430
  print_buy_report(report, result, parsed[:json])
269
- report[:checkout] || report[:browse] ? 0 : 1
431
+ buy_exit_code(report)
270
432
  end
271
433
  private_class_method :execute_buy
272
434
 
435
+ def self.buy_options(parsed, url, product_id: nil)
436
+ options = parsed[:buy].merge(url: url, confidence_check: parsed[:confidence_check],
437
+ handoff_target: parsed[:handoff_target], json: !parsed[:json].nil?)
438
+ options[:product_id] ||= product_id
439
+ options[:webmcp_bridge] = profile_webmcp_bridge(url) if parsed[:handoff_target].profile?
440
+ options
441
+ end
442
+ private_class_method :buy_options
443
+
444
+ # `needs_approval` exits 0 like Buy's own `needs_confirmation`: the run
445
+ # did what it could and is waiting on the person.
446
+ def self.buy_exit_code(report)
447
+ report[:outcome] == "needs_approval" || report[:checkout] || report[:browse] ? 0 : 1
448
+ end
449
+ private_class_method :buy_exit_code
450
+
451
+ def self.real_run?(parsed) = parsed[:buy][:yes] && !parsed[:buy][:dry_run]
452
+ private_class_method :real_run?
453
+
454
+ # An approved quote already passed ApprovalPolicy in #buy_from_quote.
455
+ def self.approval_gate?(parsed)
456
+ real_run?(parsed) && !parsed[:quote_record] && ApprovalPolicy.level != "off"
457
+ end
458
+ private_class_method :approval_gate?
459
+
460
+ # A gated run that got as far as a priced checkout saved a quote; one
461
+ # that didn't (no match, a dead end) is reported as it is — nothing
462
+ # was bought either way.
463
+ def self.needs_approval_report(report)
464
+ quote = report[:quote_id] && Quotes.new.find(report[:quote_id])
465
+ return report unless quote
466
+
467
+ summary = Approve.summary(quote)
468
+ report.merge(Approve.needs_approval(summary, message: approval_message(summary, ApprovalPolicy.level)))
469
+ end
470
+ private_class_method :needs_approval_report
471
+
472
+ def self.approval_message(summary, level)
473
+ id = summary[:quote_id]
474
+ held = if summary[:approved_by]
475
+ "Quote #{id} was approved by #{summary[:approved_by]}, which require_approval #{level} doesn't accept."
476
+ else
477
+ "Quote #{id} (#{Approve.describe(summary)}) needs approval first (require_approval: #{level})."
478
+ end
479
+ how = level == "person" ? "`portage approve #{id} --via tty` at a terminal" : "`portage approve #{id}`"
480
+ "Nothing was bought. #{held} Approve it with #{how}, then run `portage buy --quote #{id} --yes`."
481
+ end
482
+ private_class_method :approval_message
483
+
484
+ # A dry run saves a quote and reports its `quote_id`. A run of a saved
485
+ # quote spends it once it purchases or hands off; any other outcome
486
+ # (needs_confirmation, a dry run, an error) leaves it usable.
487
+ def self.settle_quote(report, parsed, options)
488
+ quote = parsed[:quote_record]
489
+ if quote
490
+ Quotes.new.consume(quote["quote_id"]) if report[:outcome] == "purchased" || report[:handoff]
491
+ return report[:outcome] == "quote_changed" ? report.merge(quote_id: quote["quote_id"]) : report
492
+ end
493
+ return report unless report[:outcome] == "dry_run"
494
+
495
+ saved = Quotes.new.create(offer_ref: parsed[:offer_ref], store: report[:url],
496
+ product_id: options[:product_id], query: options[:query], qty: options[:qty],
497
+ total: report_total(report), currency: report[:currency],
498
+ **quote_page(report, parsed))
499
+ saved ? report.merge(quote_id: saved["quote_id"]) : report
500
+ end
501
+ private_class_method :settle_quote
502
+
503
+ # What `portage approve` shows (docs/plans/human-pick-and-approve.md
504
+ # Phase 2): the title of what's in the checkout, else the picked
505
+ # offer's, and the offer's product page when the buy came from one.
506
+ def self.quote_page(report, parsed)
507
+ page = parsed[:page] || {}
508
+ { title: Array(report[:items]).first&.dig(:title) || page[:title], url: page[:url] }
509
+ end
510
+ private_class_method :quote_page
511
+
512
+ # docs/plans/buy-skill-and-local-browser.md Phase 6: `--handoff-target
513
+ # profile` gives `portage buy` a browser of its own — the Portage
514
+ # profile — so it's attached here as Buy's `webmcp_bridge:`, exactly
515
+ # the seam Buy#initialize's own doc comment already names as "the only
516
+ # option from the portage buy CLI" before this phase existed. Never
517
+ # raises: any failure to attach (portage-ucp-webmcp not installed, the
518
+ # profile not running, a bad target) just means Buy runs with no
519
+ # bridge at all — its dead-end hand-off to "profile" then reports that
520
+ # the browser isn't attached (see #dispatch_to_target's "profile"
521
+ # case) rather than this crashing the whole buy.
522
+ def self.profile_webmcp_bridge(url)
523
+ return nil unless Webmcp.available?
524
+
525
+ profile = BrowserProfile::Profile.new
526
+ return nil unless profile.status[:running]
527
+
528
+ full_url = absolute_url(url)
529
+ target = browser_profile_target(profile, full_url)
530
+ ws_url = target && target["webSocketDebuggerUrl"]
531
+ return nil unless ws_url
532
+
533
+ socket = BrowserProfile::CdpSocket.connect(ws_url)
534
+ allowlist = BrowserProfile::Allowlist.new(hosts: [URI(full_url).host])
535
+ BrowserProfile::Bridge.new(socket: socket, allowlist: allowlist)
536
+ rescue StandardError
537
+ nil
538
+ end
539
+ private_class_method :profile_webmcp_bridge
540
+
541
+ # Same "bare host gets an https:// prefix" normalization Buy#initialize
542
+ # applies to the same `url` — done again here since this runs before
543
+ # Buy exists to do it, and a bare host like "shop.example" isn't a
544
+ # URI CDP's own `/json/new` or Runtime.evaluate's `window.location`
545
+ # comparisons can parse a host out of otherwise.
546
+ def self.absolute_url(url)
547
+ raw = url.to_s.strip
548
+ raw =~ %r{\Ahttps?://}i ? raw : "https://#{raw}"
549
+ end
550
+ private_class_method :absolute_url
551
+
552
+ # An existing tab already on this store's host, so a shopper who's
553
+ # mid-session there isn't yanked to a fresh one; otherwise a brand new
554
+ # tab navigated straight to `url`.
555
+ def self.browser_profile_target(profile, url)
556
+ host = URI(url).host
557
+ existing = BrowserProfile::Cdp.list(port: profile.port)
558
+ .find { |t| t["type"] == "page" && same_host?(t["url"], host) }
559
+ existing || BrowserProfile::Cdp.new_tab(port: profile.port, url: url)
560
+ end
561
+ private_class_method :browser_profile_target
562
+
563
+ def self.same_host?(url, host)
564
+ URI(url.to_s).host == host
565
+ rescue URI::InvalidURIError
566
+ false
567
+ end
568
+ private_class_method :same_host?
569
+
273
570
  # docs/plans/handoff-reconcile.md Phase 3 — `portage buy --wait`. A
274
571
  # no-op (returns nil) whenever there's nothing to wait on: --dry-run
275
572
  # never hands off at all, and a completed/browse-only/dead-end report
@@ -361,14 +658,14 @@ module Portage
361
658
  # any other, with outcome `invalid_option`, so an agent loop reading
362
659
  # stdout gets JSON rather than nothing and a line on stderr.
363
660
  # @return [nil]
364
- def self.invalid_buy_option(message, url:, json:)
661
+ def self.invalid_buy_option(message, url:, json:, outcome: "invalid_option")
365
662
  unless json
366
663
  warn message
367
664
  return nil
368
665
  end
369
666
 
370
667
  puts JSON.pretty_generate(url: url, checkout_url: nil, products: [], warnings: [], source: "none",
371
- outcome: "invalid_option", browse: false, checkout: false, message: message)
668
+ outcome: outcome, browse: false, checkout: false, message: message)
372
669
  nil
373
670
  end
374
671
  private_class_method :invalid_buy_option
@@ -411,8 +708,9 @@ module Portage
411
708
  parser = buy_option_parser(buy, parsed)
412
709
  ProxySettings.add_options(parser, parsed[:proxy])
413
710
  parser.parse!(argv)
711
+ url = reinterpret_bare_query(url, buy, parsed)
414
712
  buy[:query] ||= ""
415
- return parsed if url || !buy[:query].strip.empty?
713
+ return parsed if buy_target?(url, buy, parsed)
416
714
 
417
715
  warn USAGE
418
716
  nil
@@ -421,6 +719,29 @@ module Portage
421
719
  end
422
720
  private_class_method :parse_buy_options
423
721
 
722
+ def self.buy_target?(url, buy, parsed)
723
+ url || !buy[:query].strip.empty? || parsed[:offer] || parsed[:quote]
724
+ end
725
+ private_class_method :buy_target?
726
+
727
+ # Bare arg is normally the store URL (`portage buy <url> --query "..."`),
728
+ # but `portage buy "coffee"` — no --query, and "coffee" doesn't look like
729
+ # a URL/domain — means the same thing as `portage buy --query "coffee"`:
730
+ # search first, then buy from whatever the caller picks. Only reinterpret
731
+ # when no --query was already given, so `portage buy shop.com --query
732
+ # "coffee"` keeps treating "shop.com" as the store.
733
+ def self.reinterpret_bare_query(url, buy, parsed)
734
+ return url unless url && buy[:query].to_s.strip.empty? && !url_like?(url)
735
+
736
+ buy[:url] = nil
737
+ parsed[:find][:query] = buy[:query] = url
738
+ nil
739
+ end
740
+ private_class_method :reinterpret_bare_query
741
+
742
+ def self.url_like?(text) = text.match?(%r{\A[a-z][a-z0-9+.-]*://}i) || text.include?(".")
743
+ private_class_method :url_like?
744
+
424
745
  def self.buy_option_parser(buy, parsed)
425
746
  OptionParser.new do |parser|
426
747
  parser.on("--qty N", Integer) { |v| buy[:qty] = v }
@@ -428,6 +749,7 @@ module Portage
428
749
  parser.on("--product-id ID") { |v| buy[:product_id] = v }
429
750
  parser.on("--yes") { buy[:yes] = true }
430
751
  parser.on("--dry-run") { buy[:dry_run] = true }
752
+ parser.on("--autofill") { buy[:autofill] = true }
431
753
  parser.on("--json") { parsed[:json] = true }
432
754
  add_handoff_options(parser, buy)
433
755
  add_wait_options(parser, parsed)
@@ -448,6 +770,7 @@ module Portage
448
770
  def self.add_handoff_options(parser, buy)
449
771
  parser.on("--[no-]auto-open") { |v| buy[:auto_open] = v }
450
772
  parser.on("--notify-webhook URL") { |v| buy[:notify_webhook] = v }
773
+ parser.on("--handoff-target TARGET") { |v| buy[:handoff_target] = v }
451
774
  end
452
775
  private_class_method :add_handoff_options
453
776
 
@@ -465,6 +788,8 @@ module Portage
465
788
  # and the catalog search once a store is settled, so it's registered once
466
789
  # here rather than twice on the same parser.
467
790
  def self.add_search_options(parser, buy, parsed)
791
+ parser.on("--offer REF") { |v| parsed[:offer] = v }
792
+ parser.on("--quote QUOTE_ID") { |v| parsed[:quote] = v }
468
793
  parser.on("--query QUERY") { |v| parsed[:find][:query] = buy[:query] = v }
469
794
  parser.on("--store URL") { |v| parsed[:store] = v }
470
795
  parser.on("--limit N", Integer) { |v| parsed[:find][:limit] = v }
@@ -472,6 +797,94 @@ module Portage
472
797
  end
473
798
  private_class_method :add_search_options
474
799
 
800
+ # --- pick / approve (docs/plans/human-pick-and-approve.md Phase 2) ---
801
+
802
+ # Outcomes a pick/approve run exits 0 on: an answer, a page shown, or a
803
+ # question handed to the agent. Everything else (cancelled, not found,
804
+ # used, refused, no terminal) exits 1.
805
+ PROMPT_OK_OUTCOMES = %w[picked approved viewed needs_pick needs_approval].freeze
806
+
807
+ def self.run_pick(argv)
808
+ opts = parse_prompt_options(argv) do |parser, o|
809
+ parser.on("--search ID") { |v| o[:search] = v }
810
+ parser.on("--choose REF") { |v| o[:choose] = v }
811
+ parser.on("--compare REF") { |v| o[:compare] = v }
812
+ parser.on("--view REF") { |v| o[:view] = v }
813
+ end
814
+ return 1 unless opts
815
+
816
+ pick = Pick.new(prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]), comparer: method(:compare_offer),
817
+ **opts.slice(:search, :choose, :view, :compare))
818
+ print_prompt_result(pick.call, opts[:json])
819
+ end
820
+ private_class_method :run_pick
821
+
822
+ def self.run_approve(argv)
823
+ quote_id = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
824
+ opts = parse_prompt_options(argv) do |parser, o|
825
+ parser.on("--relayed-yes") { o[:relayed_yes] = true }
826
+ parser.on("--view") { o[:view] = true }
827
+ end
828
+ return 1 unless opts
829
+ return prompt_usage(opts[:json], "portage approve needs a QUOTE_ID.") unless quote_id
830
+
831
+ approve = Approve.new(quote_id: quote_id, prompt: HumanPrompt.new(via: opts[:via], json: opts[:json]),
832
+ **opts.slice(:relayed_yes, :view))
833
+ print_prompt_result(approve.call, opts[:json])
834
+ end
835
+ private_class_method :run_approve
836
+
837
+ # `--via`/`--json` for both commands, plus whatever the block adds.
838
+ # @return [Hash, nil] nil on a bad flag (already reported).
839
+ def self.parse_prompt_options(argv)
840
+ opts = { via: "auto" }
841
+ json = argv.include?("--json")
842
+ OptionParser.new do |parser|
843
+ parser.on("--via SURFACE", HumanPrompt::VIAS) { |v| opts[:via] = v }
844
+ parser.on("--json") { opts[:json] = true }
845
+ yield parser, opts
846
+ end.parse!(argv)
847
+ opts
848
+ rescue OptionParser::ParseError => e
849
+ prompt_usage(json, e.message)
850
+ nil
851
+ end
852
+ private_class_method :parse_prompt_options
853
+
854
+ def self.prompt_usage(json, message)
855
+ json ? puts(JSON.pretty_generate(outcome: "invalid_option", message: message)) : warn("#{message}\n#{USAGE}")
856
+ 1
857
+ end
858
+ private_class_method :prompt_usage
859
+
860
+ # Pick's "Compare an offer across stores": the same Compare run
861
+ # `portage compare` does, from the saved offer's store and product.
862
+ def self.compare_offer(offer)
863
+ return { offers: [], message: "Couldn't compare — see the proxy error above." } unless apply_proxy_settings({})
864
+
865
+ Compare.new(origin_url: offer["store"], origin_product_id: offer["product_id"]).call
866
+ end
867
+ private_class_method :compare_offer
868
+
869
+ def self.print_prompt_result(result, json)
870
+ puts json ? JSON.pretty_generate(result) : format_prompt_result(result)
871
+ PROMPT_OK_OUTCOMES.include?(result[:outcome]) ? 0 : 1
872
+ end
873
+ private_class_method :print_prompt_result
874
+
875
+ def self.format_prompt_result(result)
876
+ lines = ["[#{result[:outcome]}] #{result[:message]}"]
877
+ Array(result[:choices]).each_with_index { |choice, index| lines << " #{index + 1}. #{choice_line(choice)}" }
878
+ lines << " #{approval_line(result[:summary])}" if result[:summary]
879
+ lines.join("\n")
880
+ end
881
+ private_class_method :format_prompt_result
882
+
883
+ def self.choice_line(choice)
884
+ [choice[:label], choice[:url], ("ref #{choice[:ref]}" if choice[:ref])].compact.join(" — ")
885
+ end
886
+ private_class_method :choice_line
887
+
475
888
  # --- history ---
476
889
 
477
890
  def self.run_history(argv)
@@ -665,53 +1078,110 @@ module Portage
665
1078
  end
666
1079
  private_class_method :run_policy
667
1080
 
1081
+ # `require_approval` is always shown at its effective value, the
1082
+ # default included, so "what does `--yes` need right now" is never a
1083
+ # guess.
668
1084
  def self.run_policy_show(argv)
669
1085
  json = false
670
1086
  OptionParser.new { |parser| parser.on("--json") { json = true } }.parse!(argv)
671
- policy = Portage::Ucp::Policy.load.to_h
672
- puts json ? JSON.pretty_generate(policy) : format_policy(policy)
1087
+ policy = Portage::Ucp::Policy.load
1088
+ effective = policy.to_h.merge(ApprovalPolicy::KEY => ApprovalPolicy.level(policy))
1089
+ puts json ? JSON.pretty_generate(effective) : format_policy(policy)
673
1090
  0
674
1091
  end
675
1092
  private_class_method :run_policy_show
676
1093
 
677
1094
  def self.format_policy(policy)
678
- return "(no policy configured — every check passes)" if policy.empty?
679
-
680
- JSON.pretty_generate(policy)
1095
+ spending = policy.to_h.except(ApprovalPolicy::KEY)
1096
+ body = spending.empty? ? "(no policy configured — every spending check passes)" : JSON.pretty_generate(spending)
1097
+ default = " (default)" unless ApprovalPolicy.configured?(policy)
1098
+ "#{body}\nrequire_approval: #{ApprovalPolicy.level(policy)}#{default}"
681
1099
  end
682
1100
  private_class_method :format_policy
683
1101
 
684
1102
  def self.parse_policy_set_options(argv)
685
1103
  opts = { allow: [] }
686
1104
  OptionParser.new do |parser|
687
- parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
688
- parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
689
- parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
690
- parser.on("--currency CUR") { |v| opts[:currency] = v }
1105
+ add_policy_cap_options(parser, opts)
691
1106
  parser.on("--velocity-count N", Integer) { |v| opts[:velocity_count] = v }
692
1107
  parser.on("--velocity-window-seconds N", Integer) { |v| opts[:velocity_window_seconds] = v }
693
1108
  parser.on("--allow HOST") { |v| opts[:allow] << v }
694
1109
  parser.on("--clear-allowlist") { opts[:clear_allowlist] = true }
1110
+ parser.on("--require-approval LEVEL", ApprovalPolicy::LEVELS) { |v| opts[:require_approval] = v }
695
1111
  end.parse!(argv)
696
1112
  opts
697
1113
  end
698
1114
  private_class_method :parse_policy_set_options
699
1115
 
1116
+ def self.add_policy_cap_options(parser, opts)
1117
+ parser.on("--per-transaction-cap N", Integer) { |v| opts[:per_transaction_cap] = v }
1118
+ parser.on("--rolling-cap N", Integer) { |v| opts[:rolling_cap] = v }
1119
+ parser.on("--rolling-window-seconds N", Integer) { |v| opts[:rolling_window_seconds] = v }
1120
+ parser.on("--currency CUR") { |v| opts[:currency] = v }
1121
+ end
1122
+ private_class_method :add_policy_cap_options
1123
+
700
1124
  # Each `--*` group is applied independently and only when its required
701
1125
  # fields are present — `portage policy set --allow shop.example.com`
702
1126
  # touches only the allowlist, leaving caps/velocity untouched, so caps
703
1127
  # and the allowlist can be configured in separate invocations.
1128
+ #
1129
+ # `--require-approval` goes first: a lowering the person doesn't confirm
1130
+ # at the terminal refuses the whole invocation, so nothing else in it
1131
+ # changes either.
704
1132
  def self.run_policy_set(argv)
705
1133
  opts = parse_policy_set_options(argv)
706
1134
  policy = Portage::Ucp::Policy.load
1135
+ return 1 unless require_approval_applied?(policy, opts[:require_approval])
1136
+
707
1137
  set_policy_cap(policy, opts)
708
1138
  set_policy_velocity(policy, opts)
709
1139
  set_policy_allowlist(policy, opts)
710
- puts format_policy(policy.to_h)
1140
+ puts format_policy(policy)
711
1141
  0
1142
+ rescue OptionParser::ParseError => e
1143
+ warn "#{e.message}\n#{USAGE}"
1144
+ 1
712
1145
  end
713
1146
  private_class_method :run_policy_set
714
1147
 
1148
+ # docs/plans/human-pick-and-approve.md Phase 2: raising the level (or
1149
+ # setting the same one) needs nothing; lowering it (person -> any/off,
1150
+ # any -> off) needs a yes typed on the tty, since an agent with a shell
1151
+ # can run `policy set` but can't type on /dev/tty. No terminal, no
1152
+ # change.
1153
+ # @return [Boolean] false when a lowering was refused (nothing changed).
1154
+ def self.require_approval_applied?(policy, level)
1155
+ return true unless level
1156
+
1157
+ current = ApprovalPolicy.level(policy)
1158
+ return false if ApprovalPolicy.lowering?(current, level) && !confirm_lowering(current, level)
1159
+
1160
+ policy.set(ApprovalPolicy::KEY, level)
1161
+ true
1162
+ end
1163
+ private_class_method :require_approval_applied?
1164
+
1165
+ def self.confirm_lowering(current, level)
1166
+ prompt = HumanPrompt.new(via: "tty")
1167
+ return true if prompt.confirm("Lower require_approval from #{current} to #{level}? #{lowering_effect(level)}")
1168
+
1169
+ warn "require_approval left at #{current}."
1170
+ false
1171
+ rescue HumanPrompt::NoTerminal
1172
+ warn "Lowering require_approval (#{current} -> #{level}) needs a yes typed at a terminal, and there's no " \
1173
+ "terminal here — nothing changed. Run it yourself from a terminal."
1174
+ false
1175
+ end
1176
+ private_class_method :confirm_lowering
1177
+
1178
+ def self.lowering_effect(level)
1179
+ return "`portage buy --yes` would then buy without anyone approving the total." if level == "off"
1180
+
1181
+ "An agent relaying your yes would then be enough to buy."
1182
+ end
1183
+ private_class_method :lowering_effect
1184
+
715
1185
  def self.set_policy_cap(policy, opts)
716
1186
  if opts[:per_transaction_cap]
717
1187
  policy.set("per_transaction_cap",
@@ -757,6 +1227,9 @@ module Portage
757
1227
  when "pending"
758
1228
  "Timed out waiting for enrollment — finish it at #{result[:setup_url]}, then run " \
759
1229
  "`portage payment enroll` again."
1230
+ when "handoff_only"
1231
+ "#{result[:host]} is hand-off only — there's nothing to set up here. Sign in and add a card " \
1232
+ "on #{result[:host]} yourself."
760
1233
  else "This store doesn't support payment enrollment."
761
1234
  end
762
1235
  end
@@ -833,6 +1306,379 @@ module Portage
833
1306
  end
834
1307
  private_class_method :format_reconcile_result
835
1308
 
1309
+ # --- index (docs/plans/buy-skill-and-local-browser.md Phase 2b) ---
1310
+
1311
+ INDEX_SUBCOMMANDS = {
1312
+ "build" => ->(argv) { run_index_build(argv, refresh: false) },
1313
+ "refresh" => ->(argv) { run_index_build(argv, refresh: true) },
1314
+ "show" => ->(argv) { run_index_show(argv) },
1315
+ "add" => ->(argv) { run_index_add(argv) },
1316
+ "remove" => ->(argv) { run_index_remove(argv) },
1317
+ "sources" => ->(argv) { run_index_sources(argv) }
1318
+ }.freeze
1319
+
1320
+ def self.run_index(argv)
1321
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1322
+ return INDEX_SUBCOMMANDS[sub].call(argv) if INDEX_SUBCOMMANDS.key?(sub)
1323
+
1324
+ warn USAGE
1325
+ 1
1326
+ end
1327
+ private_class_method :run_index
1328
+
1329
+ def self.parse_index_build_options(argv)
1330
+ opts = { sources: nil, queries: nil, dry_run: false, json: false, export: nil }
1331
+ OptionParser.new do |parser|
1332
+ parser.on("--sources LIST") { |v| opts[:sources] = v.split(",").map(&:strip) }
1333
+ parser.on("--queries FILE") { |v| opts[:queries] = v }
1334
+ parser.on("--dry-run") { opts[:dry_run] = true }
1335
+ parser.on("--export DIR") { |v| opts[:export] = v }
1336
+ parser.on("--json") { opts[:json] = true }
1337
+ end.parse!(argv)
1338
+ opts[:queries] &&= File.readlines(opts[:queries]).map(&:strip).reject(&:empty?)
1339
+ opts
1340
+ end
1341
+ private_class_method :parse_index_build_options
1342
+
1343
+ def self.run_index_build(argv, refresh:)
1344
+ opts = parse_index_build_options(argv)
1345
+ builder = Index::Builder.new(sources: index_sources(opts[:sources]), out: opts[:json] ? nil : $stdout)
1346
+ result = if refresh
1347
+ builder.refresh(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
1348
+ else
1349
+ builder.build(queries: opts[:queries], dry_run: opts[:dry_run], export: opts[:export])
1350
+ end
1351
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_build(result)
1352
+ 0
1353
+ end
1354
+ private_class_method :run_index_build
1355
+
1356
+ def self.index_sources(names) = names ? Index::Sources.by_name(names) : nil
1357
+ private_class_method :index_sources
1358
+
1359
+ def self.format_index_build(result)
1360
+ lines = ["Ran #{result[:sources_run].join(', ')} — #{result[:candidates]} candidate(s)."]
1361
+ lines << "Checked #{result[:new_origins_checked].length} new origin(s), " \
1362
+ "#{result[:verified].length} verified UCP."
1363
+ lines << "Hit the #{Index::Builder::MAX_NEW_PROBES}-probe cap for this run." if result[:capped]
1364
+ lines << "#{result[:products_added]} product sighting(s) recorded." if result[:products_added]
1365
+ if result[:exported]
1366
+ lines << "Exported #{result[:exported][:stores]} store(s), #{result[:exported][:products]} " \
1367
+ "product(s) to #{result[:exported][:dir]}."
1368
+ end
1369
+ lines.join("\n")
1370
+ end
1371
+ private_class_method :format_index_build
1372
+
1373
+ def self.run_index_show(argv)
1374
+ opts = { kind: nil, json: false }
1375
+ OptionParser.new do |parser|
1376
+ parser.on("--stores") { opts[:kind] = "stores" }
1377
+ parser.on("--products") { opts[:kind] = "products" }
1378
+ parser.on("--json") { opts[:json] = true }
1379
+ end.parse!(argv)
1380
+
1381
+ result = { stores: opts[:kind] == "products" ? [] : Index::Store.new.all,
1382
+ products: opts[:kind] == "stores" ? [] : Index::ProductStore.new.all }
1383
+ puts opts[:json] ? JSON.pretty_generate(result) : format_index_show(result)
1384
+ 0
1385
+ end
1386
+ private_class_method :run_index_show
1387
+
1388
+ 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?
1392
+ lines << "Products:"
1393
+ result[:products].each { |p| lines << " #{p['title'] || p['key']}" }
1394
+ lines << "(none)" if result[:products].empty?
1395
+ lines.join("\n")
1396
+ end
1397
+ private_class_method :format_index_show
1398
+
1399
+ def self.run_index_add(argv)
1400
+ json = argv.delete("--json") ? true : false
1401
+ url = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1402
+ unless url
1403
+ warn USAGE
1404
+ return 1
1405
+ end
1406
+
1407
+ result = Index::Builder.new.add(url)
1408
+ puts json ? JSON.pretty_generate(result) : result[:message]
1409
+ result[:added] ? 0 : 1
1410
+ end
1411
+ private_class_method :run_index_add
1412
+
1413
+ def self.run_index_remove(argv)
1414
+ json = argv.delete("--json") ? true : false
1415
+ host = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1416
+ unless host
1417
+ warn USAGE
1418
+ return 1
1419
+ end
1420
+
1421
+ result = Index::Builder.new.remove(host)
1422
+ puts json ? JSON.pretty_generate(result) : result[:message]
1423
+ result[:removed] ? 0 : 1
1424
+ end
1425
+ private_class_method :run_index_remove
1426
+
1427
+ def self.run_index_sources(argv)
1428
+ json = argv.delete("--json") ? true : false
1429
+ sources = Index::Sources.all.map { |s| { name: s.name, description: s.description, path: s.source_path } }
1430
+ puts json ? JSON.pretty_generate(sources) : format_index_sources(sources)
1431
+ 0
1432
+ end
1433
+ private_class_method :run_index_sources
1434
+
1435
+ def self.format_index_sources(sources)
1436
+ sources.map { |s| "#{s[:name]}: #{s[:description]}#{" (#{s[:path]})" if s[:path]}" }.join("\n")
1437
+ end
1438
+ private_class_method :format_index_sources
1439
+
1440
+ # --- browser (docs/plans/buy-skill-and-local-browser.md Phase 3) ---
1441
+
1442
+ BROWSER_SUBCOMMANDS = { "import" => ->(argv) { run_browser_import(argv) },
1443
+ "profile" => ->(argv) { run_browser_profile(argv) } }.freeze
1444
+
1445
+ def self.run_browser(argv)
1446
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1447
+ return BROWSER_SUBCOMMANDS[sub].call(argv) if BROWSER_SUBCOMMANDS.key?(sub)
1448
+
1449
+ warn USAGE
1450
+ 1
1451
+ end
1452
+ private_class_method :run_browser
1453
+
1454
+ def self.parse_browser_import_options(argv)
1455
+ opts = { browser: nil, root: nil, history_days: BrowserImport::Importer::DEFAULT_HISTORY_DAYS,
1456
+ include_product_pages: false, max_probes: BrowserImport::Importer::MAX_PROBES, exclude: [],
1457
+ dry_run: false, yes: false, json: false }
1458
+ browser_import_option_parser(opts).parse!(argv)
1459
+ opts
1460
+ end
1461
+ private_class_method :parse_browser_import_options
1462
+
1463
+ def self.browser_import_option_parser(opts)
1464
+ OptionParser.new do |parser|
1465
+ parser.on("--browser NAME", BrowserImport::Profiles::BROWSERS) { |v| opts[:browser] = v }
1466
+ parser.on("--profile-root DIR") { |v| opts[:root] = File.expand_path(v) }
1467
+ parser.on("--history-days N", Integer) { |v| opts[:history_days] = v }
1468
+ parser.on("--max-probes N", Integer) { |v| opts[:max_probes] = v }
1469
+ parser.on("--exclude HOSTS", Array) { |v| opts[:exclude] = v.map(&:strip) }
1470
+ %i[include_product_pages dry_run yes json].each do |flag|
1471
+ parser.on("--#{flag.to_s.tr('_', '-')}") { opts[flag] = true }
1472
+ end
1473
+ end
1474
+ end
1475
+ private_class_method :browser_import_option_parser
1476
+
1477
+ # Reads, reduces and probes (BrowserImport::Importer#plan), shows the
1478
+ # list, and saves only through BrowserImport::Confirm's gate: `--yes`,
1479
+ # or a "y" at a real TTY prompt. Under `--json`/no TTY without `--yes`
1480
+ # nothing is written — the report says `saved: false,
1481
+ # needs_confirmation: true` and exits 0, so an agent shows the user the
1482
+ # list and re-runs with `--yes` only once they've approved it.
1483
+ def self.run_browser_import(argv)
1484
+ opts = parse_browser_import_options(argv)
1485
+ plan = browser_importer.plan(browser_import_options(opts))
1486
+ return report_browser_import(plan, opts, nil) if plan[:error]
1487
+
1488
+ puts format_browser_import(plan) unless opts[:json]
1489
+ interactive = !opts[:json] && $stdin.tty?
1490
+ decision = BrowserImport::Confirm.new(interactive: interactive).call(plan, yes: opts[:yes],
1491
+ dry_run: opts[:dry_run])
1492
+ saved = decision == :save ? browser_importer.save(plan) : nil
1493
+ report_browser_import(plan, opts, decision, saved)
1494
+ rescue OptionParser::ParseError => e
1495
+ warn "#{e.message}\n#{USAGE}"
1496
+ 1
1497
+ end
1498
+ private_class_method :run_browser_import
1499
+
1500
+ # The real HandoffOnly list (docs/plans/buy-skill-and-local-browser.md
1501
+ # Phase 5) into Importer's own injectable `handoff_only_hosts:` seam
1502
+ # (Phase 3 left it defaulting to `[]` for exactly this) — a history/
1503
+ # bookmark domain on the list is kept as `handoff_only: true` and never
1504
+ # probed.
1505
+ def self.browser_importer
1506
+ BrowserImport::Importer.new(handoff_only_hosts: HandoffOnly.new.hosts)
1507
+ end
1508
+ private_class_method :browser_importer
1509
+
1510
+ def self.browser_import_options(opts)
1511
+ browser = opts[:browser] || BrowserImport::Profiles.detect || "chrome"
1512
+ BrowserImport::Importer::Options.new(
1513
+ browser: browser, root: opts[:root] || BrowserImport::Profiles.default_root(browser),
1514
+ history_days: opts[:history_days], include_product_pages: opts[:include_product_pages],
1515
+ max_probes: opts[:max_probes], exclude: opts[:exclude]
1516
+ )
1517
+ end
1518
+ private_class_method :browser_import_options
1519
+
1520
+ BROWSER_IMPORT_MESSAGES = {
1521
+ dry_run: "Dry run — nothing saved.",
1522
+ nothing: "No shop domains to save.",
1523
+ declined: "Nothing saved.",
1524
+ needs_confirmation: "Nothing saved: there's no terminal to confirm on. Show this list to the user, then " \
1525
+ "re-run with --yes once they've approved it (--exclude HOST,... drops any they don't want)."
1526
+ }.freeze
1527
+
1528
+ def self.report_browser_import(plan, opts, decision, saved = nil)
1529
+ message = plan[:message] || browser_import_message(decision, saved)
1530
+ if opts[:json]
1531
+ puts JSON.pretty_generate(plan.merge(saved: !saved.nil?, needs_confirmation: decision == :needs_confirmation,
1532
+ message: message))
1533
+ else
1534
+ puts message
1535
+ end
1536
+ plan[:error] ? 1 : 0
1537
+ end
1538
+ private_class_method :report_browser_import
1539
+
1540
+ def self.browser_import_message(decision, saved)
1541
+ return "Saved #{saved[:stores]} store(s) and #{saved[:products]} product(s) to your local index." if saved
1542
+
1543
+ BROWSER_IMPORT_MESSAGES.fetch(decision, "Nothing saved.")
1544
+ end
1545
+ private_class_method :browser_import_message
1546
+
1547
+ def self.format_browser_import(plan)
1548
+ lines = browser_import_counts(plan)
1549
+ lines << "Shops found (#{plan[:kept].length}):"
1550
+ plan[:kept].each { |entry| lines << " #{browser_import_line(entry)}" }
1551
+ lines << " (none)" if plan[:kept].empty?
1552
+ lines << "#{plan[:products].length} product page(s) to keep." if plan[:products].any?
1553
+ lines.join("\n")
1554
+ end
1555
+ private_class_method :format_browser_import
1556
+
1557
+ def self.browser_import_counts(plan)
1558
+ skipped = plan[:skipped].map { |reason, n| "#{n} #{reason}" }.join(", ")
1559
+ lines = ["Read #{plan[:rows][:history]} history and #{plan[:rows][:bookmark]} bookmark row(s) across " \
1560
+ "#{plan[:domains]} domain(s) from #{plan[:browser]}.",
1561
+ "Skipped #{skipped.empty? ? 'none' : skipped}; probed #{plan[:probed]} " \
1562
+ "(#{plan[:not_ucp]} without UCP, #{plan[:cached_miss]} already known not to)."]
1563
+ lines << "Hit the #{plan[:probed]}-probe cap; #{plan[:unprobed]} domain(s) left unprobed." if plan[:capped]
1564
+ lines
1565
+ end
1566
+ private_class_method :browser_import_counts
1567
+
1568
+ def self.browser_import_line(entry)
1569
+ categories = entry[:category_names].empty? ? "uncategorised" : entry[:category_names].join(", ")
1570
+ "#{entry[:domain]} — #{entry[:verdict]} — #{categories} " \
1571
+ "(#{entry[:sources].join(', ')}, #{entry[:visits]} visit(s))"
1572
+ end
1573
+ private_class_method :browser_import_line
1574
+
1575
+ # --- browser profile (docs/plans/buy-skill-and-local-browser.md Phase 6) ---
1576
+
1577
+ BROWSER_PROFILE_SUBCOMMANDS = {
1578
+ "init" => ->(argv) { run_browser_profile_init(argv) },
1579
+ "open" => ->(argv) { run_browser_profile_open(argv) },
1580
+ "status" => ->(argv) { run_browser_profile_status(argv) }
1581
+ }.freeze
1582
+
1583
+ def self.run_browser_profile(argv)
1584
+ sub = argv.first && !argv.first.start_with?("-") ? argv.shift : nil
1585
+ return browser_profile_usage unless BROWSER_PROFILE_SUBCOMMANDS.key?(sub)
1586
+
1587
+ BROWSER_PROFILE_SUBCOMMANDS[sub].call(argv)
1588
+ end
1589
+ private_class_method :run_browser_profile
1590
+
1591
+ def self.browser_profile_usage
1592
+ warn USAGE
1593
+ 1
1594
+ end
1595
+ private_class_method :browser_profile_usage
1596
+
1597
+ def self.parse_browser_profile_options(argv)
1598
+ opts = { browser: nil, port: BrowserProfile::Profile::DEFAULT_PORT, url: nil, json: false }
1599
+ OptionParser.new do |parser|
1600
+ parser.on("--browser NAME", BrowserProfile::Browsers::CHROMIUM) { |v| opts[:browser] = v }
1601
+ parser.on("--port N", Integer) { |v| opts[:port] = v }
1602
+ parser.on("--url URL") { |v| opts[:url] = v }
1603
+ parser.on("--json") { opts[:json] = true }
1604
+ end.parse!(argv)
1605
+ opts
1606
+ end
1607
+ private_class_method :parse_browser_profile_options
1608
+
1609
+ # `--browser` names the exact browser; without it, the first Chromium
1610
+ # family browser BrowserImport::Profiles finds installed, falling back
1611
+ # to "chrome" — same "pick something reasonable, let --browser
1612
+ # override" posture as run_browser_import's own default.
1613
+ def self.browser_profile_for(opts)
1614
+ browser = opts[:browser] || BrowserProfile::Browsers.detect || "chrome"
1615
+ BrowserProfile::Profile.new(browser: browser, port: opts[:port])
1616
+ end
1617
+ private_class_method :browser_profile_for
1618
+
1619
+ def self.run_browser_profile_init(argv)
1620
+ opts = parse_browser_profile_options(argv)
1621
+ result = browser_profile_for(opts).init!
1622
+ puts opts[:json] ? JSON.pretty_generate(result) : "Profile ready at #{result[:dir]} (#{result[:browser]})."
1623
+ 0
1624
+ rescue OptionParser::ParseError => e
1625
+ warn "#{e.message}\n#{USAGE}"
1626
+ 1
1627
+ end
1628
+ private_class_method :run_browser_profile_init
1629
+
1630
+ # Launches the profile (if it isn't already running on its own port)
1631
+ # and either opens a new tab at --url or attaches to the first
1632
+ # existing one. Never touches the browser's default profile — Profile
1633
+ # itself only ever points --user-data-dir at its own dedicated
1634
+ # directory.
1635
+ def self.run_browser_profile_open(argv)
1636
+ opts = parse_browser_profile_options(argv)
1637
+ result = browser_profile_for(opts).open!(url: opts[:url])
1638
+ puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_open(result)
1639
+ 0
1640
+ rescue BrowserProfile::Error => e
1641
+ report_browser_profile_error(e, opts[:json])
1642
+ rescue OptionParser::ParseError => e
1643
+ warn "#{e.message}\n#{USAGE}"
1644
+ 1
1645
+ end
1646
+ private_class_method :run_browser_profile_open
1647
+
1648
+ def self.run_browser_profile_status(argv)
1649
+ opts = parse_browser_profile_options(argv)
1650
+ result = browser_profile_for(opts).status
1651
+ puts opts[:json] ? JSON.pretty_generate(result) : format_browser_profile_status(result)
1652
+ 0
1653
+ rescue OptionParser::ParseError => e
1654
+ warn "#{e.message}\n#{USAGE}"
1655
+ 1
1656
+ end
1657
+ private_class_method :run_browser_profile_status
1658
+
1659
+ def self.format_browser_profile_open(result)
1660
+ tab = result.dig(:target, "url")
1661
+ "#{result[:browser]} profile is open (port #{result[:port]}, #{result[:dir]})#{" — tab: #{tab}" if tab}."
1662
+ end
1663
+ private_class_method :format_browser_profile_open
1664
+
1665
+ def self.format_browser_profile_status(result)
1666
+ return "#{result[:browser]} profile (#{result[:dir]}) isn't running." unless result[:running]
1667
+
1668
+ "#{result[:browser]} profile is running on port #{result[:port]} (#{result[:dir]})."
1669
+ end
1670
+ private_class_method :format_browser_profile_status
1671
+
1672
+ def self.report_browser_profile_error(error, json)
1673
+ if json
1674
+ puts JSON.pretty_generate(error: error.class.name.split("::").last, message: error.message)
1675
+ else
1676
+ warn error.message
1677
+ end
1678
+ 1
1679
+ end
1680
+ private_class_method :report_browser_profile_error
1681
+
836
1682
  # --- doctor ---
837
1683
 
838
1684
  def self.parse_doctor_options(argv)
@@ -847,7 +1693,12 @@ module Portage
847
1693
  end
848
1694
  private_class_method :parse_doctor_options
849
1695
 
850
- def self.run_doctor(argv)
1696
+ # `wizard: :force` is `portage setup`, always offering the wizard on a
1697
+ # TTY; `:auto` is `doctor`/`configure`, which only offers it when
1698
+ # Doctor#nothing_configured? — the read-only report is what every other
1699
+ # run of `doctor` still gets, exactly as before this phase
1700
+ # (docs/plans/buy-skill-and-local-browser.md Phase 4).
1701
+ def self.run_doctor(argv, wizard: :auto)
851
1702
  opts = parse_doctor_options(argv)
852
1703
  require File.expand_path(opts[:require]) if opts[:require]
853
1704
  adapter_class = opts[:adapter] && Object.const_get(opts[:adapter])
@@ -856,10 +1707,31 @@ module Portage
856
1707
 
857
1708
  doctor = Doctor.new(adapter_class: adapter_class, proxy_settings: proxy_settings,
858
1709
  seller: !(opts[:require] || opts[:adapter]).nil?)
1710
+ return run_setup_wizard if run_wizard?(wizard, opts, doctor)
1711
+
859
1712
  report_doctor(doctor.call, json: opts[:json])
860
1713
  end
861
1714
  private_class_method :run_doctor
862
1715
 
1716
+ def self.run_setup(argv) = run_doctor(argv, wizard: :force)
1717
+ private_class_method :run_setup
1718
+
1719
+ # --json or no TTY on stdin always stays today's read-only report,
1720
+ # whichever command name was used — a piped/CI/agent run never blocks
1721
+ # on a prompt it can't answer.
1722
+ def self.run_wizard?(mode, opts, doctor)
1723
+ return false if opts[:json] || !$stdin.tty?
1724
+ return true if mode == :force
1725
+
1726
+ doctor.nothing_configured?
1727
+ end
1728
+ private_class_method :run_wizard?
1729
+
1730
+ def self.run_setup_wizard
1731
+ SetupWizard.new.call
1732
+ end
1733
+ private_class_method :run_setup_wizard
1734
+
863
1735
  def self.report_doctor(findings, json:)
864
1736
  puts json ? JSON.pretty_generate(findings.map(&:to_h)) : format_doctor(findings)
865
1737
  findings.none?(&:warning?) ? 0 : 1
@@ -934,6 +1806,7 @@ module Portage
934
1806
  lines = ["[#{report[:outcome]}] #{report[:message]} (source: #{report[:source]})"]
935
1807
  report[:products].each { |p| lines << " - #{product_line(p)}" }
936
1808
  lines.concat(format_checkout(report))
1809
+ lines.concat(format_quote(report))
937
1810
  lines << " checkout: #{report[:checkout_url]}" if report[:checkout_url]
938
1811
  lines.concat(format_handoff(report[:handoff])) if report[:handoff]
939
1812
  lines.concat(format_decisions(report[:decisions])) if report[:decisions]&.any?
@@ -941,6 +1814,18 @@ module Portage
941
1814
  end
942
1815
  private_class_method :format_report
943
1816
 
1817
+ def self.format_quote(report)
1818
+ lines = report[:quote_id] ? [" quote: #{report[:quote_id]}"] : []
1819
+ lines << " approve: #{approval_line(report[:summary])}" if report[:summary]
1820
+ lines
1821
+ end
1822
+ private_class_method :format_quote
1823
+
1824
+ def self.approval_line(summary)
1825
+ [Approve.describe(summary), summary[:url]].compact.join(" — ")
1826
+ end
1827
+ private_class_method :approval_line
1828
+
944
1829
  # What the checkout holds, as opposed to the search results above it,
945
1830
  # and where it differs from the request.
946
1831
  def self.format_checkout(report)
@@ -973,6 +1858,7 @@ module Portage
973
1858
  def self.format_find(report)
974
1859
  lines = [report[:message].to_s]
975
1860
  report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{offer_line(offer)}" }
1861
+ lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
976
1862
  lines.join("\n")
977
1863
  end
978
1864
  private_class_method :format_find
@@ -980,6 +1866,7 @@ module Portage
980
1866
  def self.offer_line(offer)
981
1867
  parts = ["#{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
982
1868
  parts << "browse only" unless offer[:checkout]
1869
+ parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
983
1870
  parts.join(" — ")
984
1871
  end
985
1872
  private_class_method :offer_line
@@ -987,6 +1874,7 @@ module Portage
987
1874
  def self.format_compare(report)
988
1875
  lines = [report[:message].to_s]
989
1876
  report[:offers].each_with_index { |offer, index| lines << " #{index + 1}. #{compare_offer_line(offer)}" }
1877
+ lines << " search: #{report[:search_id]} (portage pick --search #{report[:search_id]})" if report[:search_id]
990
1878
  lines.join("\n")
991
1879
  end
992
1880
  private_class_method :format_compare
@@ -994,6 +1882,7 @@ module Portage
994
1882
  def self.compare_offer_line(offer)
995
1883
  parts = ["[#{offer[:match]}] #{offer[:store]} — #{offer[:title]} (#{offer[:product_id]})", format_price(offer)]
996
1884
  parts << "browse only" unless offer[:checkout]
1885
+ parts << "ref #{offer[:offer_ref]}" if offer[:offer_ref]
997
1886
  parts.join(" — ")
998
1887
  end
999
1888
  private_class_method :compare_offer_line
@@ -1005,9 +1894,7 @@ module Portage
1005
1894
  end
1006
1895
  private_class_method :format_price
1007
1896
 
1008
- def self.format_amount(amount, currency)
1009
- "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
1010
- end
1897
+ def self.format_amount(amount, currency) = Money.format_amount(amount, currency)
1011
1898
  private_class_method :format_amount
1012
1899
  end
1013
1900
  end