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
@@ -0,0 +1,31 @@
1
+ module Portage
2
+ module Cli
3
+ class SetupWizard
4
+ module Steps
5
+ # Step 5 (docs/plans/buy-skill-and-local-browser.md Phase 4):
6
+ # `portage index build` (Phases 2b-2c). Opt-in and off by default —
7
+ # it's a live network sweep of Shopify's global catalog, one query
8
+ # per taxonomy node, which is a lot to run unasked inside a wizard.
9
+ # Delegates to Cli.run_index_build so the command's own sources,
10
+ # probe cap and progress output are exactly what a direct
11
+ # `portage index build` would give.
12
+ class IndexBuild
13
+ def initialize(prompt:) = @prompt = prompt
14
+
15
+ def title = "Local store index"
16
+ def default_yes? = false
17
+
18
+ def call
19
+ @prompt.say("Builds ~/.portage/index/{stores,products}.json — stores and products `find` can " \
20
+ "route by on top of stores.yml, the known-stores list this repo already fetches, " \
21
+ "and web search. Takes a little while: one query per product-category node against " \
22
+ "Shopify's catalog, verifying each new store with a single /.well-known/ucp probe.")
23
+ return unless @prompt.confirm("Run `portage index build` now?", default: false)
24
+
25
+ Portage::Cli.send(:run_index_build, [], refresh: false)
26
+ end
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,60 @@
1
+ module Portage
2
+ module Cli
3
+ class SetupWizard
4
+ module Steps
5
+ # Step 6 (docs/plans/buy-skill-and-local-browser.md Phase 4):
6
+ # spending policy caps (Portage::Ucp::PolicyGuard), via `portage
7
+ # policy set` — reused, not reimplemented, so the actual cap/
8
+ # allowlist logic (Cli.set_policy_cap/set_policy_allowlist) has
9
+ # exactly one place it lives.
10
+ class Policy
11
+ def initialize(prompt:) = @prompt = prompt
12
+
13
+ def title = "Spending policy caps"
14
+ def default_yes? = true
15
+
16
+ def call
17
+ @prompt.say("Checked before every `portage buy --yes` checkout completes. Enter skips a " \
18
+ "question you don't want to answer right now — nothing here is required.")
19
+ args = cap_args + allowlist_args
20
+ return @prompt.say("No policy changes made.") if args.empty?
21
+
22
+ Portage::Cli.send(:run_policy_set, args)
23
+ end
24
+
25
+ private
26
+
27
+ def cap_args
28
+ major = @prompt.ask("Per-transaction spending cap", hint: "major units, e.g. 200 for $200; Enter to skip")
29
+ return [] unless major
30
+
31
+ parsed = Float(major, exception: false)
32
+ return not_a_number unless parsed&.positive?
33
+
34
+ currency = @prompt.ask("Currency for that cap", hint: "e.g. USD")
35
+ return no_currency if currency.nil?
36
+
37
+ # Cli.to_minor_units is private — reused via `send` rather than
38
+ # a second implementation of major-to-minor-units conversion.
39
+ ["--per-transaction-cap", Portage::Cli.send(:to_minor_units, parsed).to_s, "--currency", currency]
40
+ end
41
+
42
+ def not_a_number
43
+ @prompt.say("Not a number — cap not set.")
44
+ []
45
+ end
46
+
47
+ def no_currency
48
+ @prompt.say("No currency given — cap not set.")
49
+ []
50
+ end
51
+
52
+ def allowlist_args
53
+ hosts = @prompt.ask("Merchant allowlist hosts to add", hint: "comma-separated, Enter to skip")
54
+ Array(hosts&.split(",")).map(&:strip).reject(&:empty?).flat_map { |host| ["--allow", host] }
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,55 @@
1
+ require_relative "../../dot_env"
2
+
3
+ module Portage
4
+ module Cli
5
+ class SetupWizard
6
+ module Steps
7
+ # Phase 7 (docs/plans/buy-skill-and-local-browser.md): the five
8
+ # official retailer offer sources (OfferSources) are each opt-in —
9
+ # this step is where their keys get written, same as SearchKeys
10
+ # does for Brave/Google CSE. Every one of these still ends in
11
+ # hand-off (see HandoffOnly / OfferSources.retail_handoff_host?):
12
+ # setting a key here only makes `find` show more real offers, it
13
+ # never lets `portage buy` complete a purchase at any of these
14
+ # retailers.
15
+ class RetailerKeys
16
+ FIELDS = {
17
+ "WALMART_AFFILIATE_API_KEY" => "Walmart Affiliate API key",
18
+ "EBAY_BROWSE_ACCESS_TOKEN" => "eBay Browse API access token (Buy It Now search only)",
19
+ "BESTBUY_API_KEY" => "Best Buy Products API key",
20
+ "ETSY_LISTINGS_API_KEY" => "Etsy Open API v3 key (buyer-side listing search)",
21
+ "AMAZON_CREATORS_ACCESS_TOKEN" => "Amazon Creators API access token"
22
+ }.freeze
23
+
24
+ def initialize(prompt:) = @prompt = prompt
25
+
26
+ def title = "Retailer offer sources"
27
+ def default_yes? = false
28
+
29
+ def call
30
+ @prompt.say("Optional: official buyer-side APIs for Walmart, eBay (Buy It Now only), Best " \
31
+ "Buy, Etsy and Amazon. Each needs its own key from that retailer's developer " \
32
+ "program — skip any you don't have. None of these ever completes a purchase: " \
33
+ "every offer they return still ends in hand-off, same as Amazon today. Neither " \
34
+ "key is echoed back; Enter keeps whatever's already set.")
35
+ assignments = collect
36
+ return @prompt.say("Left retailer offer source keys unchanged.") if assignments.empty?
37
+
38
+ path = DotEnv.update!(assignments)
39
+ @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
40
+ end
41
+
42
+ private
43
+
44
+ def collect
45
+ FIELDS.each_with_object({}) do |(var, label), assignments|
46
+ hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
47
+ answer = @prompt.ask_secret(label, hint: hint)
48
+ assignments[var] = answer if answer
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
55
+ end
@@ -0,0 +1,54 @@
1
+ require_relative "../../dot_env"
2
+
3
+ module Portage
4
+ module Cli
5
+ class SetupWizard
6
+ module Steps
7
+ # Step 2 (docs/plans/buy-skill-and-local-browser.md Phase 4): Brave
8
+ # and Google CSE search keys, written to ~/.portage/.env. See
9
+ # Doctor#search_backend_finding — with neither set, `find`/`buy`
10
+ # with no URL only ever resolves DuckDuckGo's Instant Answer API,
11
+ # which is keyless but only ever matches a specific brand/product
12
+ # name, not an open-ended query ("coffee", "hiking boots").
13
+ class SearchKeys
14
+ FIELDS = {
15
+ "BRAVE_SEARCH_API_KEY" => "Brave Search API key",
16
+ "GOOGLE_CSE_KEY" => "Google Programmable Search API key",
17
+ "GOOGLE_CSE_CX" => "Google Programmable Search engine id (cx)"
18
+ }.freeze
19
+
20
+ # Google's `cx` names which engine to query, not a credential —
21
+ # only the two API keys are secrets that never echo back.
22
+ SECRET = %w[BRAVE_SEARCH_API_KEY GOOGLE_CSE_KEY].freeze
23
+
24
+ def initialize(prompt:) = @prompt = prompt
25
+
26
+ def title = "Search API keys"
27
+ def default_yes? = true
28
+
29
+ def call
30
+ @prompt.say("DuckDuckGo's free API is already active and needs no key, but only resolves a " \
31
+ "specific brand/product name — set either Brave's key, or both Google fields, for " \
32
+ "real open-ended search. Neither key is echoed back; Enter keeps whatever's " \
33
+ "already set.")
34
+ assignments = collect
35
+ return @prompt.say("Left search API keys unchanged.") if assignments.empty?
36
+
37
+ path = DotEnv.update!(assignments)
38
+ @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
39
+ end
40
+
41
+ private
42
+
43
+ def collect
44
+ FIELDS.each_with_object({}) do |(var, label), assignments|
45
+ hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
46
+ answer = SECRET.include?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
47
+ assignments[var] = answer if answer
48
+ end
49
+ end
50
+ end
51
+ end
52
+ end
53
+ end
54
+ end
@@ -0,0 +1,51 @@
1
+ require_relative "../../shipping_profile"
2
+ require_relative "../../dot_env"
3
+
4
+ module Portage
5
+ module Cli
6
+ class SetupWizard
7
+ module Steps
8
+ # Step 1 (docs/plans/buy-skill-and-local-browser.md Phase 4):
9
+ # PORTAGE_SHIP_*, written to ~/.portage/.env. Without at least the
10
+ # required fields, `portage buy` sends no shipping destination to
11
+ # your own store's checkout, and without the country a native UCP
12
+ # store prices in no market and can report in-stock items as out of
13
+ # stock (see Doctor#shipping_finding, which this step exists to
14
+ # resolve).
15
+ class Shipping
16
+ LABELS = {
17
+ street_address: "Street address", extended_address: "Apt/suite (optional)",
18
+ address_locality: "City", address_region: "State/region (optional)",
19
+ address_country: "Country (ISO 3166-1 alpha-2, e.g. US, GB)", postal_code: "Postal code",
20
+ first_name: "First name (optional)", last_name: "Last name (optional)",
21
+ phone_number: "Phone (optional)"
22
+ }.freeze
23
+
24
+ def initialize(prompt:) = @prompt = prompt
25
+
26
+ def title = "Shipping address"
27
+ def default_yes? = true
28
+
29
+ def call
30
+ @prompt.say("Enter keeps whatever's already set for a field.")
31
+ assignments = collect
32
+ return @prompt.say("Left shipping address unchanged.") if assignments.empty?
33
+
34
+ path = DotEnv.update!(assignments)
35
+ @prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
36
+ end
37
+
38
+ private
39
+
40
+ def collect
41
+ ShippingProfile::ENV_VARS.each_with_object({}) do |(key, var), assignments|
42
+ hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
43
+ answer = @prompt.ask(LABELS.fetch(key), hint: hint)
44
+ assignments[var] = answer if answer
45
+ end
46
+ end
47
+ end
48
+ end
49
+ end
50
+ end
51
+ end
@@ -0,0 +1,74 @@
1
+ require_relative "setup_wizard/prompt"
2
+ require_relative "setup_wizard/steps/shipping"
3
+ require_relative "setup_wizard/steps/search_keys"
4
+ require_relative "setup_wizard/steps/retailer_keys"
5
+ require_relative "setup_wizard/steps/agent_profile"
6
+ require_relative "setup_wizard/steps/browser_import"
7
+ require_relative "setup_wizard/steps/index_build"
8
+ require_relative "setup_wizard/steps/policy"
9
+ require_relative "setup_wizard/steps/handoff"
10
+
11
+ module Portage
12
+ module Cli
13
+ # `portage setup`'s interactive wizard (docs/plans/
14
+ # buy-skill-and-local-browser.md Phase 4) — Cli.run_doctor/run_setup
15
+ # decide *whether* to run this (a real TTY, no --json, and either the
16
+ # command was `setup` outright or `doctor`/`configure` found nothing
17
+ # configured at all; see Doctor#nothing_configured?); this class is
18
+ # just the steps themselves, once that decision is already made
19
+ # (Phase 4 shipped seven; Phase 7 added RetailerKeys as an eighth, opt
20
+ # -in step between search keys and the agent profile).
21
+ #
22
+ # Every step: says what it's for, offers to skip (Enter keeps its own
23
+ # default — "on" for the foundational steps, "off" for the ones that
24
+ # are opt-in: browser import and index build's network/filesystem
25
+ # sweeps, and RetailerKeys' per-retailer credentials), and re-runs
26
+ # cleanly — Enter on any individual question inside a step
27
+ # always keeps whatever's already set, never clears it. No step raises
28
+ # its own error class: each one leans on the command it delegates to
29
+ # (`portage generate agent-profile`, `portage browser import`, `portage
30
+ # index build`, `portage policy set`) for its own validation and error
31
+ # reporting, so this class has nothing UCP- or network-specific to
32
+ # rescue.
33
+ class SetupWizard
34
+ INTRO = <<~TEXT.freeze
35
+ Portage setup
36
+
37
+ Walks through shipping, search, your agent profile, browser import,
38
+ the local store index, spending caps and hand-off. Every step can
39
+ be skipped, and this re-runs cleanly at any time — nothing you
40
+ already have set is cleared just by pressing Enter.
41
+ TEXT
42
+
43
+ STEPS = [Steps::Shipping, Steps::SearchKeys, Steps::RetailerKeys, Steps::AgentProfile, Steps::BrowserImport,
44
+ Steps::IndexBuild, Steps::Policy, Steps::Handoff].freeze
45
+
46
+ def initialize(input: $stdin, output: $stdout)
47
+ @prompt = Prompt.new(input: input, output: output)
48
+ end
49
+
50
+ # @return [Integer] always 0 — nothing about walking through (or
51
+ # skipping) setup steps is a failure exit code; a step that hits a
52
+ # real problem (a bad flag reaching `portage policy set`, a browser
53
+ # import permission error) reports it itself, same as running that
54
+ # command directly would.
55
+ def call
56
+ @prompt.say(INTRO)
57
+ STEPS.each { |step_class| run_step(step_class) }
58
+ @prompt.say("\nDone — run `portage doctor` any time to see the current state, or `portage setup` " \
59
+ "again to change anything above.")
60
+ 0
61
+ end
62
+
63
+ private
64
+
65
+ def run_step(step_class)
66
+ step = step_class.new(prompt: @prompt)
67
+ @prompt.say("\n== #{step.title} ==")
68
+ return @prompt.say("Skipped.") unless @prompt.confirm("Configure this now?", default: step.default_yes?)
69
+
70
+ step.call
71
+ end
72
+ end
73
+ end
74
+ end
@@ -1,5 +1,5 @@
1
1
  module Portage
2
2
  module Cli
3
- VERSION = "0.7.4".freeze
3
+ VERSION = "0.8.0".freeze
4
4
  end
5
5
  end
@@ -8,18 +8,25 @@ module Portage
8
8
  # caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
9
9
  # installed.
10
10
  module Webmcp
11
- # @return [Boolean] whether portage-ucp-webmcp could be loaded.
12
- # Memoized: `require` runs once per process.
11
+ # WebMcp::Autofill, Presets, Matcher and Fingerprint, which the CLI
12
+ # calls, first shipped in portage-ucp-webmcp 0.2.0. An older install
13
+ # counts as not installed rather than failing with a NameError later.
14
+ MIN_VERSION = "0.2.0".freeze
15
+
16
+ # @return [Boolean] whether a new-enough portage-ucp-webmcp could be
17
+ # loaded. Memoized: `require` runs once per process.
13
18
  def self.available?
14
19
  return @available unless @available.nil?
15
20
 
16
21
  @available = begin
17
22
  require "portage/ucp/webmcp"
18
- true
23
+ supported?(Portage::Ucp::WebMcp::VERSION)
19
24
  rescue LoadError
20
25
  false
21
26
  end
22
27
  end
28
+
29
+ def self.supported?(version) = Gem::Version.new(version) >= Gem::Version.new(MIN_VERSION)
23
30
  end
24
31
  end
25
32
  end
@@ -0,0 +1,38 @@
1
+ module Portage
2
+ module Cli
3
+ # docs/plans/webmcp-universal-outbound.md Phase 3, decision 2: even once
4
+ # `--autofill`/`PORTAGE_WEBMCP_AUTOFILL=approve` has opted a run in
5
+ # (WebmcpAutofillMode), nothing is typed into the store's checkout page
6
+ # until the shopper approves, in this prompt, exactly which fields and
7
+ # values are about to be entered — same shape as Phase 2's
8
+ # WebmcpMappingConfirm, applied to autofill instead of a tool mapping.
9
+ #
10
+ # Fields is always contact email + shipping address here — WebmcpFields
11
+ # never builds a payment one — but this class has no opinion on that;
12
+ # it only ever shows the shopper what it was given and asks.
13
+ class WebmcpAutofillConfirm
14
+ # @param interactive [Boolean] false under --json or with no TTY on
15
+ # stdin — same posture as WebmcpMappingConfirm: no interactive
16
+ # prompt possible means no autofill, not a guessed default.
17
+ def initialize(interactive:, input: $stdin, output: $stdout)
18
+ @interactive = interactive
19
+ @input = input
20
+ @output = output
21
+ end
22
+
23
+ # @param fields [Hash{String=>String}] autocomplete token => value.
24
+ # @return [Boolean] whether the shopper approved filling exactly these
25
+ # fields. false for an empty `fields` too — nothing to confirm.
26
+ def call(fields)
27
+ return false if !@interactive || fields.empty?
28
+
29
+ @output.puts "Autofill is ready to enter this on the store's own checkout page — contact and " \
30
+ "shipping only, never payment:"
31
+ fields.each { |token, value| @output.puts " #{token}: #{value}" }
32
+ @output.print "Fill these fields now? [y/N] "
33
+ @output.flush
34
+ @input.gets.to_s.strip.downcase == "y"
35
+ end
36
+ end
37
+ end
38
+ end
@@ -0,0 +1,60 @@
1
+ require_relative "shipping_profile"
2
+
3
+ module Portage
4
+ module Cli
5
+ # docs/plans/webmcp-universal-outbound.md Phase 3 — the shopper-approved
6
+ # data autofill is ever allowed to type into a store's checkout page:
7
+ # contact email and the PORTAGE_SHIP_* shipping address
8
+ # (Portage::Cli::ShippingProfile), each named by the WHATWG
9
+ # autocomplete token (https://html.spec.whatwg.org/#autofill) a standards
10
+ # -following checkout page's own field should carry, which is what
11
+ # Portage::Ucp::WebMcp::Autofill/assets/autofill.js match against.
12
+ #
13
+ # Deliberately the only place in this gem that reads PORTAGE_SHIP_EMAIL
14
+ # — a contact email is a WebMCP-autofill-only concern (native/adapter
15
+ # checkouts take it as part of `context`/the adapter's own contract, not
16
+ # a standalone shipping field), so it doesn't belong on ShippingProfile
17
+ # or BuyerContext, both of which are shared with paths that never touch
18
+ # a browser.
19
+ module WebmcpAutofillFields
20
+ EMAIL_ENV_VAR = "PORTAGE_SHIP_EMAIL".freeze
21
+
22
+ # ShippingProfile attribute => the autocomplete token
23
+ # assets/autofill.js looks for on the checkout page.
24
+ TOKENS = {
25
+ first_name: "shipping given-name",
26
+ last_name: "shipping family-name",
27
+ street_address: "shipping address-line1",
28
+ extended_address: "shipping address-line2",
29
+ address_locality: "shipping address-level2",
30
+ address_region: "shipping address-level1",
31
+ postal_code: "shipping postal-code",
32
+ address_country: "shipping country",
33
+ phone_number: "shipping tel"
34
+ }.freeze
35
+
36
+ module_function
37
+
38
+ # @param address [Portage::Ucp::PostalAddress, nil] nil (the default)
39
+ # reads PORTAGE_SHIP_* fresh; injectable so a spec doesn't need real
40
+ # env vars set.
41
+ # @return [Hash{String=>String}] autocomplete token => value. Empty
42
+ # when neither an email nor a usable shipping address is
43
+ # configured — #call in Buy treats that as nothing to autofill,
44
+ # same as no PORTAGE_SHIP_* today.
45
+ def build(address: Portage::Cli::ShippingProfile.from_env)
46
+ fields = {}
47
+ email = ENV.fetch(EMAIL_ENV_VAR, nil)
48
+ fields["email"] = email unless email.to_s.strip.empty?
49
+
50
+ return fields unless address
51
+
52
+ TOKENS.each do |attribute, token|
53
+ value = address.public_send(attribute)
54
+ fields[token] = value unless value.to_s.strip.empty?
55
+ end
56
+ fields
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,39 @@
1
+ require_relative "config"
2
+ require_relative "setting"
3
+
4
+ module Portage
5
+ module Cli
6
+ # docs/plans/webmcp-universal-outbound.md Phase 3 — the opt-in gate in
7
+ # front of filling the store's own checkout page. Off by default: only
8
+ # `--autofill` (a per-run CLI flag, forwarded to `Buy.new(autofill:)`)
9
+ # or `PORTAGE_WEBMCP_AUTOFILL=approve` / config.json's
10
+ # `"webmcp_autofill": "approve"` turn it on. Same Setting precedence
11
+ # everywhere else in this gem uses (WebmcpCheckoutMode, HandoffSpendMode)
12
+ # — override beats env beats config — except the override here is a
13
+ # boolean (the flag is either passed or it isn't), while the env/config
14
+ # level is a literal string, `"approve"`, not truthy-string parsing
15
+ # (Setting.flag?'s "1"/"true"/"yes"): a plan that could be
16
+ # `PORTAGE_WEBMCP_AUTOFILL=true` by accident from some other truthy
17
+ # convention shouldn't silently turn on typing into a checkout page.
18
+ #
19
+ # Turning this gate on is still only half of Phase 3's opt-in: even
20
+ # approved, nothing is typed until the shopper also approves the exact
21
+ # field/value pairs in WebmcpAutofillConfirm's own prompt.
22
+ module WebmcpAutofillMode
23
+ ENV_VAR = "PORTAGE_WEBMCP_AUTOFILL".freeze
24
+ CONFIG_KEY = "webmcp_autofill".freeze
25
+ APPROVE = "approve".freeze
26
+
27
+ module_function
28
+
29
+ # @param override [Boolean, nil] --autofill's value: true when the
30
+ # flag was passed, nil otherwise (there's no --no-autofill; the flag
31
+ # only ever turns this on, same as --dry-run/--yes elsewhere in Buy).
32
+ def approved?(override: nil, config: Config.load)
33
+ return true if override == true
34
+
35
+ Setting.resolve(env: ENV_VAR, config: config, config_key: CONFIG_KEY).to_s.strip.downcase == APPROVE
36
+ end
37
+ end
38
+ end
39
+ end
@@ -0,0 +1,68 @@
1
+ module Portage
2
+ module Cli
3
+ # Turns a `Portage::Ucp::WebMcp::Matcher` proposal into `tool_names:`
4
+ # `Buy#webmcp_flow` can pass to `WebMcp.connect`, enforcing Phase 2's
5
+ # confirm-before-mutate rule (docs/plans/webmcp-universal-outbound.md):
6
+ # a read action (`search_catalog`, `get_product`, `get_cart`) is usable
7
+ # straight off the proposal — wrong at worst wastes a call. A mutating
8
+ # action (`create_cart`, `update_cart`, `create_checkout`) needs
9
+ # confirmation first, since a wrong guess there could add to, discard,
10
+ # or charge a stranger's cart.
11
+ #
12
+ # The prompt quotes a mutating tool's own `description` as what it is —
13
+ # page content, therefore untrusted — never as an instruction. Nothing
14
+ # here executes, follows, or acts on anything the description says; it
15
+ # is printed for the shopper to read and nothing more.
16
+ class WebmcpMappingConfirm
17
+ READ_ACTIONS = %w[search_catalog get_product get_cart].freeze
18
+
19
+ # @param interactive [Boolean] whether a prompt can actually be shown
20
+ # and answered — false under --json or with no TTY on stdin (the
21
+ # caller decides; this class has no opinion on how). false means a
22
+ # proposal with any mutating action is always refused.
23
+ def initialize(interactive:, input: $stdin, output: $stdout)
24
+ @interactive = interactive
25
+ @input = input
26
+ @output = output
27
+ end
28
+
29
+ # @param proposal [Hash{String => Matcher::Proposal}]
30
+ # @param tools [Array<Hash>] the page's own tools, so the prompt can
31
+ # quote a mutating tool's description.
32
+ # @return [Hash{String => String}, nil] a tool_names: hash covering
33
+ # every proposed action, or nil when a mutating action was proposed
34
+ # and couldn't be confirmed — no TTY, --json, or the shopper
35
+ # declined. Never returns a partial mapping: either every mutating
36
+ # action in the proposal is approved, or none of them are used.
37
+ def call(proposal, tools)
38
+ reads = proposal.slice(*READ_ACTIONS).transform_values(&:tool)
39
+ mutating = proposal.except(*READ_ACTIONS)
40
+ return reads if mutating.empty?
41
+ return nil unless confirmed?(mutating, tools)
42
+
43
+ reads.merge(mutating.transform_values(&:tool))
44
+ end
45
+
46
+ private
47
+
48
+ def confirmed?(mutating, tools) = @interactive && approve?(mutating, tools)
49
+
50
+ def approve?(mutating, tools)
51
+ @output.puts "This page's WebMCP tools weren't recognized as a known store platform. Based on their " \
52
+ "names and schemas, here's the proposed mapping for cart/checkout actions — the " \
53
+ "description text is quoted from the page itself, not a trusted instruction:"
54
+ mutating.each { |action, match| describe(action, match, tools) }
55
+ @output.print "Use this mapping? [y/N] "
56
+ @output.flush
57
+ @input.gets.to_s.strip.downcase == "y"
58
+ end
59
+
60
+ def describe(action, match, tools)
61
+ tool = tools.find { |t| (t["name"] || t[:name]).to_s == match.tool }
62
+ description = tool && (tool["description"] || tool[:description])
63
+ @output.puts " #{action} -> #{match.tool} (confidence #{match.confidence}) — #{match.reason}"
64
+ @output.puts " page description: #{description.inspect}" if description
65
+ end
66
+ end
67
+ end
68
+ end
@@ -0,0 +1,84 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "time"
4
+
5
+ module Portage
6
+ module Cli
7
+ # Confirmed Phase 2 `tool_names:` mappings — `~/.portage/webmcp_mappings.json`
8
+ # (docs/plans/webmcp-universal-outbound.md) — keyed by the page's tool
9
+ # fingerprint (`Portage::Ucp::WebMcp::Fingerprint.for`: sorted tool names
10
+ # plus a hash of each one's own input schema), never by origin
11
+ # (decision 3): once a shopper confirms a mapping for one store, any
12
+ # other store whose WebMCP tools have the exact same shape reuses it
13
+ # with no prompt at all — in effect a local preset. A lookalike page
14
+ # whose schemas differ even slightly gets a different fingerprint and
15
+ # has to be confirmed again, so it can't borrow a mapping with a
16
+ # different shape underneath the same tool names.
17
+ #
18
+ # Same "absent file or absent key means unset, a corrupt file raises
19
+ # rather than silently falling back" posture as Config — this is
20
+ # deliberately its own file, not a key under config.json, since it's
21
+ # confirmed data (grows with use) rather than a standing preference.
22
+ class WebmcpMappings
23
+ PATH = File.join(Dir.home, ".portage", "webmcp_mappings.json").freeze
24
+
25
+ def self.load(path: PATH) = new(path: path, data: read(path))
26
+
27
+ def initialize(path: PATH, data: {})
28
+ @path = path
29
+ @data = data
30
+ end
31
+
32
+ # @param tools [Array<Hash>] the page's tools, as list_tools returns
33
+ # them.
34
+ # @return [Hash{String => String}, nil] the confirmed tool_names: for
35
+ # this exact fingerprint, or nil when none has been confirmed yet.
36
+ def lookup(tools)
37
+ entry = @data[fingerprint_for(tools)]
38
+ entry && entry["tool_names"].transform_keys(&:to_s)
39
+ end
40
+
41
+ # @param tool_names [Hash] action => tool name, as just confirmed.
42
+ # Merged onto whatever this fingerprint already had (so confirming a
43
+ # new action later doesn't drop an earlier one), never replaced
44
+ # outright.
45
+ # @param origin [String, nil] recorded as metadata only — the lookup
46
+ # key is the fingerprint alone (decision 3), never the origin.
47
+ # @return [Hash{String => String}] the fingerprint's tool_names: after
48
+ # merging.
49
+ def confirm!(tools, tool_names:, origin: nil)
50
+ key = fingerprint_for(tools)
51
+ entry = @data[key] || { "tool_names" => {}, "origins" => [] }
52
+ entry["tool_names"] = entry["tool_names"].merge(tool_names.transform_keys(&:to_s))
53
+ entry["origins"] = (entry["origins"] + [origin]).compact.uniq if origin
54
+ entry["confirmed_at"] = Time.now.utc.iso8601
55
+ @data[key] = entry
56
+ write
57
+ entry["tool_names"]
58
+ end
59
+
60
+ def to_h = @data.dup
61
+
62
+ def self.read(path)
63
+ return {} unless File.readable?(path)
64
+
65
+ raw = File.read(path)
66
+ return {} if raw.empty?
67
+
68
+ parsed = JSON.parse(raw)
69
+ parsed.is_a?(Hash) ? parsed : {}
70
+ end
71
+ private_class_method :read
72
+
73
+ private
74
+
75
+ def fingerprint_for(tools) = Portage::Ucp::WebMcp::Fingerprint.for(tools)
76
+
77
+ def write
78
+ FileUtils.mkdir_p(File.dirname(@path))
79
+ File.write(@path, JSON.pretty_generate(@data))
80
+ File.chmod(0o600, @path)
81
+ end
82
+ end
83
+ end
84
+ end