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
@@ -0,0 +1,60 @@
1
+ require_relative "../../agent_profile_url"
2
+ require_relative "../../dot_env"
3
+
4
+ module Portage
5
+ module Cli
6
+ class SetupWizard
7
+ module Steps
8
+ # Step 3 (docs/plans/buy-skill-and-local-browser.md Phase 4): the
9
+ # agent profile every catalog/cart/checkout call carries
10
+ # (PORTAGE_AGENT_PROFILE). Phase 1 already defaults it to this
11
+ # repo's own published profile (AgentProfileUrl), so this step's
12
+ # job is explaining that default and, opt-in, generating and
13
+ # hosting your own — it never runs generation on its own.
14
+ #
15
+ # `portage generate agent-profile` itself is reused verbatim (via
16
+ # Cli.run_generate_agent_profile), not reimplemented, so its own
17
+ # output (where the files went, the "commit + purge the jsdelivr
18
+ # cache" next step) stays the one place that message lives.
19
+ class AgentProfile
20
+ def initialize(prompt:) = @prompt = prompt
21
+
22
+ def title = "Agent profile"
23
+ def default_yes? = true
24
+
25
+ def call
26
+ @prompt.say("Falls back to this repo's own published profile right now:\n " \
27
+ "#{AgentProfileUrl.resolve}\n" \
28
+ "That's fine to keep using. Generating your own is only worth it once you're " \
29
+ "hosting Portage for real, rather than trying it out.")
30
+ return unless @prompt.confirm("Generate your own agent profile now?", default: false)
31
+
32
+ generate
33
+ host
34
+ end
35
+
36
+ private
37
+
38
+ def generate
39
+ out = @prompt.ask("Where to write the public profile", hint: "default: agent-profile.json") ||
40
+ "agent-profile.json"
41
+ key_out = @prompt.ask("Where to write the private signing key", hint: "default: agent-profile.key.pem") ||
42
+ "agent-profile.key.pem"
43
+ Portage::Cli.send(:run_generate_agent_profile, ["--out", out, "--key-out", key_out])
44
+ end
45
+
46
+ def host
47
+ @prompt.say("Once it's hosted at a stable, public HTTPS URL (docs/agent-profile.md), point " \
48
+ "PORTAGE_AGENT_PROFILE at it.")
49
+ url = @prompt.ask("URL you'll host it at", hint: "Enter to keep using the default for now")
50
+ return unless url
51
+
52
+ path = DotEnv.update!({ "PORTAGE_AGENT_PROFILE" => url })
53
+ @prompt.say("Saved PORTAGE_AGENT_PROFILE to #{path} (chmod 600) — won't take effect until " \
54
+ "that URL actually serves the profile you just generated.")
55
+ end
56
+ end
57
+ end
58
+ end
59
+ end
60
+ end
@@ -0,0 +1,29 @@
1
+ module Portage
2
+ module Cli
3
+ class SetupWizard
4
+ module Steps
5
+ # Step 4 (docs/plans/buy-skill-and-local-browser.md Phase 4):
6
+ # `portage browser import` (Phase 3, Tier A). Opt-in and off by
7
+ # default here, same as the command itself — this step only asks
8
+ # which browser, then hands off to Cli.run_browser_import so the
9
+ # command's own read -> reduce -> probe -> show -> confirm flow
10
+ # (BrowserImport::Confirm) runs unchanged, prompt and all.
11
+ class BrowserImport
12
+ def initialize(prompt:) = @prompt = prompt
13
+
14
+ def title = "Browser import"
15
+ def default_yes? = false
16
+
17
+ def call
18
+ @prompt.say("Local-only: reduces your browser's bookmarks/history to shop domains, probes each " \
19
+ "unknown one for /.well-known/ucp, and shows you the list before saving anything to " \
20
+ "your local index. Never reads passwords, cookies or saved-card autofill.")
21
+ browser = @prompt.ask("Which browser", hint: "chrome/edge/brave/arc/firefox/safari — Enter to autodetect")
22
+ args = browser ? ["--browser", browser] : []
23
+ Portage::Cli.send(:run_browser_import, args)
24
+ end
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,100 @@
1
+ require_relative "../../config"
2
+ require_relative "../../checkout_handoff"
3
+ require_relative "../../handoff_target"
4
+ require_relative "../../handoff_only"
5
+
6
+ module Portage
7
+ module Cli
8
+ class SetupWizard
9
+ module Steps
10
+ # Step 7 (docs/plans/buy-skill-and-local-browser.md Phase 4 built
11
+ # the auto-open toggle; Phase 5 extends it): auto-open, the
12
+ # hand-off target, an approved named agent, and the hand-off-only
13
+ # host list — all through Config, the same "Enter keeps what's
14
+ # already set" contract every other step uses.
15
+ class Handoff
16
+ def initialize(prompt:) = @prompt = prompt
17
+
18
+ def title = "Hand-off"
19
+ def default_yes? = true
20
+
21
+ def call
22
+ @prompt.say("When a buy hands off — no payment token, an escalation, or a dead end — Portage " \
23
+ "opens the checkout URL (or dispatches it to an approved agent). Amazon and any " \
24
+ "other host on your hand-off-only list are never automated: Portage just opens the " \
25
+ "page and you buy. #{HandoffOnly::LEGAL_NOTICE}")
26
+ toggle_auto_open
27
+ set_handoff_target
28
+ approve_agent
29
+ edit_handoff_only_hosts
30
+ end
31
+
32
+ private
33
+
34
+ def toggle_auto_open
35
+ current = CheckoutHandoff.new.auto_open?
36
+ answer = @prompt.confirm("Auto-open the checkout URL in your browser on hand-off?", default: current)
37
+ return @prompt.say("Left unchanged.") if answer == current
38
+
39
+ Config.load.set("auto_open_checkout", answer)
40
+ @prompt.say("Saved to ~/.portage/config.json.")
41
+ end
42
+
43
+ def set_handoff_target
44
+ current = HandoffTarget.new.label
45
+ answer = @prompt.ask("Hand-off target", hint: "default, print, profile, or agent:<name>; " \
46
+ "currently #{current}; Enter to keep it")
47
+ return @prompt.say("Left unchanged.") if answer.nil?
48
+
49
+ HandoffTarget.new(override: answer) # validates before saving
50
+ Config.load.set("handoff_target", answer)
51
+ @prompt.say("Saved to ~/.portage/config.json.")
52
+ rescue ArgumentError => e
53
+ @prompt.say("#{e.message} Not saved.")
54
+ end
55
+
56
+ # Approving an `agent:<name>` target once here (or by editing
57
+ # config.json directly) is what #lookup requires before Buy will
58
+ # ever invoke it (see HandoffAgents) — nothing is approved just
59
+ # by naming it as the hand-off target above.
60
+ def approve_agent
61
+ name = @prompt.ask("Approve a named agent for agent:<name> hand-off", hint: "agent name, Enter to skip")
62
+ return unless name
63
+
64
+ command = @prompt.ask("Command to run it", hint: "space-separated argv, e.g. openclaw handoff; " \
65
+ "Enter to use a webhook instead")
66
+ return approve_agent_webhook(name) unless command
67
+
68
+ save_agent(name, "command" => command.split)
69
+ end
70
+
71
+ def approve_agent_webhook(name)
72
+ webhook = @prompt.ask("Webhook URL", hint: "https://..., Enter to skip")
73
+ return @prompt.say("No command or webhook given — #{name.inspect} not saved.") unless webhook
74
+
75
+ save_agent(name, "webhook" => webhook)
76
+ end
77
+
78
+ def save_agent(name, entry)
79
+ config = Config.load
80
+ agents = config.get("handoff_agents") || {}
81
+ agents[name] = entry.merge("approved" => true)
82
+ config.set("handoff_agents", agents)
83
+ @prompt.say("Approved #{name.inspect} — use --handoff-target agent:#{name}.")
84
+ end
85
+
86
+ def edit_handoff_only_hosts
87
+ current = HandoffOnly.new.hosts
88
+ @prompt.say("Current hand-off-only hosts (#{current.length}): #{current.join(', ')}")
89
+ answer = @prompt.ask("Replace the list", hint: "comma-separated hosts, Enter to keep it as-is")
90
+ return @prompt.say("Left unchanged.") if answer.nil?
91
+
92
+ hosts = answer.split(",").map(&:strip).reject(&:empty?)
93
+ Config.load.set("handoff_only_hosts", hosts)
94
+ @prompt.say("Saved #{hosts.length} host(s) to ~/.portage/config.json.")
95
+ end
96
+ end
97
+ end
98
+ end
99
+ end
100
+ end
@@ -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.5".freeze
3
+ VERSION = "0.9.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