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,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