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
@@ -1,6 +1,7 @@
1
1
  require "uri"
2
2
  require_relative "config"
3
3
  require_relative "setting"
4
+ require_relative "browser_opener"
4
5
 
5
6
  module Portage
6
7
  module Cli
@@ -14,13 +15,11 @@ module Portage
14
15
  # --auto-open / --no-auto-open) beats PORTAGE_AUTO_OPEN_CHECKOUT, which
15
16
  # beats ~/.portage/config.json's "auto_open_checkout" (Config).
16
17
  #
17
- # No new gem for the actual open — every other shell-out in this repo
18
- # (PaymentMethods::KeychainBackend, SecretServiceBackend) hand-rolls
19
- # `system` rather than pulling in launchy for something the OS already
20
- # provides. `system(cmd, url)` (array form, never an interpolated
21
- # string) so a merchant-controlled checkout_url can't inject into a
22
- # shell.
18
+ # The open itself is BrowserOpener's array-form `system` shell-out, so
19
+ # a merchant-controlled checkout_url can't inject into a shell.
23
20
  class CheckoutHandoff
21
+ include BrowserOpener
22
+
24
23
  ENV_VAR = "PORTAGE_AUTO_OPEN_CHECKOUT".freeze
25
24
  CONFIG_KEY = "auto_open_checkout".freeze
26
25
 
@@ -47,24 +46,6 @@ module Portage
47
46
  rescue URI::InvalidURIError
48
47
  false
49
48
  end
50
-
51
- def open_browser(url)
52
- command = platform_command
53
- return false unless command
54
-
55
- !!system(command, url)
56
- rescue StandardError => e
57
- warn "portage: couldn't open #{url} (#{e.message})"
58
- false
59
- end
60
-
61
- def platform_command
62
- case RbConfig::CONFIG["host_os"]
63
- when /darwin/i then "open"
64
- when /linux|bsd/i then "xdg-open"
65
- when /mswin|mingw|cygwin/i then "start"
66
- end
67
- end
68
49
  end
69
50
  end
70
51
  end
@@ -0,0 +1,158 @@
1
+ require "yaml"
2
+
3
+ module Portage
4
+ module Cli
5
+ # "What category is this?" for anything shopping-shaped: a search query,
6
+ # a catalog product's title/description, a stores.yml/index entry, or
7
+ # (Phase 3) a browser history/bookmark entry — one classifier, so a
8
+ # store tagged from its product mix and a query typed by a shopper land
9
+ # in the same category space and can be matched against each other.
10
+ #
11
+ # Plain keyword matching against Google's own published product taxonomy
12
+ # (https://www.google.com/basepages/producttype/taxonomy-with-ids.en-US.txt),
13
+ # top two levels only (~200 nodes, `known-stores/categories.yml`, shipped
14
+ # in the gem so this works offline on a fresh brew/gem install). No LLM
15
+ # and no network call: it has to run on every `find` and stay instant.
16
+ # "Plain keyword" means a whole-word match (plus simple plural
17
+ # normalization, #word_match?), not a substring regex — a substring
18
+ # check matches in both directions ("carpet" contains "pet", "chair"
19
+ # contains "hair", "scarf" contains "car") and was a real source of
20
+ # false positives before this became whole-word.
21
+ module Classifier
22
+ # `known-stores/categories.yml`, from `lib/portage/cli/classifier.rb`.
23
+ KNOWN_PATH = File.expand_path("../../../known-stores/categories.yml", __dir__).freeze
24
+
25
+ # The user's own additions/overrides — same id overrides a shipped
26
+ # node's keywords, a new id extends the taxonomy. Absent by default;
27
+ # nothing here is required for the shipped file to work.
28
+ PATH = File.join(Dir.home, ".portage", "categories.yml").freeze
29
+
30
+ # `/products/<slug>`, `/collections/<slug>`, `/c/<slug>`,
31
+ # `/category/<slug>` — the URL shapes stores.yml/index/browser-import
32
+ # entries actually carry (docs/plans/buy-skill-and-local-browser.md
33
+ # Phase 2a/3).
34
+ SLUG_PATTERNS = [
35
+ %r{/products/([^/?#]+)},
36
+ %r{/collections/([^/?#]+)},
37
+ %r{/c/([^/?#]+)},
38
+ %r{/category/([^/?#]+)}
39
+ ].freeze
40
+
41
+ # @param text [String] a query, a product title/description, or a
42
+ # store/product URL. Whichever it is, it's tokenized the same way
43
+ # (see #tokenize) so the same node keywords match all of them.
44
+ # @param known_path [String] override for KNOWN_PATH — specs redirect
45
+ # this the same way SearchBackends::Allowlist takes its own `path:`.
46
+ # @param user_path [String] override for PATH.
47
+ # @return [Array<String>] category ids, most keyword hits first. Ties
48
+ # keep the shipped file's own order. Empty when nothing matches.
49
+ def self.categories_for(text, known_path: KNOWN_PATH, user_path: PATH)
50
+ words = tokenize(text)
51
+ return [] if words.empty?
52
+
53
+ scored = nodes(known_path, user_path).filter_map { |id, node| rank(id, node, words) }
54
+ scored.sort_by { |(_id, score, order)| [-score, order] }.map(&:first)
55
+ end
56
+
57
+ # @return [Array<String>] the taxonomy names for `ids`, in order —
58
+ # `portage browser import` (Phase 3) shows a domain's guessed
59
+ # categories by name so the user can judge them before saving.
60
+ def self.names_for(ids, known_path: KNOWN_PATH, user_path: PATH)
61
+ all = nodes(known_path, user_path)
62
+ Array(ids).filter_map { |id| all.dig(id.to_s, "name") }
63
+ end
64
+
65
+ # --- Tokenizing the input ---
66
+
67
+ # A URL slug is words joined by `-`/`_` (`hand-cut-glass` — see
68
+ # AGENTS.md's own product terminology); everything else is just
69
+ # whitespace/punctuation-split. Both a URL's slug words and the plain
70
+ # words of a title/description/query are kept, so "hiking boots" and
71
+ # "https://shop.example/products/hiking-boots-mens" tokenize the same
72
+ # way.
73
+ # Below 3 letters, a token is noise ("a", "of", "is") rather than a
74
+ # keyword candidate — every generated keyword is at least this long
75
+ # too (see the script that built known-stores/categories.yml), so
76
+ # nothing below this length could ever whole-word match one anyway.
77
+ MIN_WORD_LENGTH = 3
78
+
79
+ # Public rather than private — SearchBackends::Index (Phase 2b) tokenizes
80
+ # a query and a product's title/aliases the same way this module does
81
+ # internally, so a query and a product name land in the same word
82
+ # space instead of re-implementing this split.
83
+ def self.tokenize(text)
84
+ string = text.to_s
85
+ slug_words = SLUG_PATTERNS.filter_map { |pattern| pattern.match(string)&.[](1) }
86
+ .flat_map { |slug| slug.split(/[-_]/) }
87
+ (slug_words + string.split(/[^\p{Alpha}]+/)).map(&:downcase)
88
+ .select { |word| word.length >= MIN_WORD_LENGTH }
89
+ end
90
+
91
+ # --- Scoring one node against the tokenized input ---
92
+
93
+ def self.rank(id, node, words)
94
+ keywords = Array(node["keywords"])
95
+ hits = keywords.count { |keyword| words.any? { |word| word_match?(word, keyword) } }
96
+ return nil unless hits.positive?
97
+
98
+ [id, hits, node["order"].to_i]
99
+ end
100
+ private_class_method :rank
101
+
102
+ # Whole-word only, plus the plural forms a keyword list and a real
103
+ # query/title actually differ by: an exact match, one plus a trailing
104
+ # "s" or "es" ("boot"/"boots", "watch"/"watches"), or the "y"/"ies"
105
+ # swap ("battery"/"batteries"). Deliberately not a substring check —
106
+ # substrings match in both directions regardless of word boundaries
107
+ # ("carpet" contains "pet", "chair" contains "hair", "scarf" contains
108
+ # "car"), which is real noise, not stemming.
109
+ # Public — see .tokenize's own comment; SearchBackends::Index (Phase
110
+ # 2b) matches a query's tokens against a product's title/alias tokens
111
+ # with this same whole-word (plus plural) rule, rather than a
112
+ # substring check that goes both ways ("tea" in "steam"/"teak", "bag"
113
+ # in "bagel").
114
+ def self.word_match?(word, keyword)
115
+ word == keyword || plural_of?(word, keyword) || plural_of?(keyword, word) || ies_y_match?(word, keyword)
116
+ end
117
+
118
+ # @return [Boolean] true when `plural` is `singular` plus a trailing
119
+ # "s" or "es" ("boot"/"boots", "watch"/"watches").
120
+ def self.plural_of?(plural, singular)
121
+ ["#{singular}s", "#{singular}es"].include?(plural)
122
+ end
123
+ private_class_method :plural_of?
124
+
125
+ # @return [Boolean] true when either side is the other's "ies" plural
126
+ # ("battery"/"batteries").
127
+ def self.ies_y_match?(word, keyword)
128
+ (word.end_with?("ies") && keyword == "#{word[0..-4]}y") ||
129
+ (keyword.end_with?("ies") && word == "#{keyword[0..-4]}y")
130
+ end
131
+ private_class_method :ies_y_match?
132
+
133
+ # --- Loading and merging the two files ---
134
+
135
+ # Re-read on every call rather than cached process-wide: `find` calls
136
+ # this once or twice per invocation, not in a hot loop, and a cached
137
+ # copy would miss an edit to ~/.portage/categories.yml until the next
138
+ # process start.
139
+ def self.nodes(known_path, user_path)
140
+ ordered = {}
141
+ load_yaml(known_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => i) }
142
+ load_yaml(user_path).each_with_index { |(id, node), i| ordered[id] = node.merge("order" => ordered.size + i) }
143
+ ordered
144
+ end
145
+ private_class_method :nodes
146
+
147
+ def self.load_yaml(path)
148
+ return {} unless path && File.readable?(path)
149
+
150
+ data = YAML.safe_load_file(path)
151
+ data.is_a?(Hash) ? data : {}
152
+ rescue StandardError
153
+ {}
154
+ end
155
+ private_class_method :load_yaml
156
+ end
157
+ end
158
+ end
@@ -1,4 +1,5 @@
1
1
  require_relative "find"
2
+ require_relative "handoff_only"
2
3
 
3
4
  module Portage
4
5
  module Cli
@@ -53,6 +54,8 @@ module Portage
53
54
  def resolve_origin
54
55
  uri = parse_http(@origin_url)
55
56
  return report(message: "Not a store URL: #{@origin_url.inspect}") unless uri
57
+ return report(message: "#{uri.host} is hand-off only — #{HandoffOnly::LEGAL_NOTICE}") \
58
+ if @handoff_only.host?(uri.host)
56
59
 
57
60
  session = discover(origin_of(uri))
58
61
  return report(message: "#{origin_of(uri)} doesn't speak UCP.") unless session
@@ -5,6 +5,12 @@ require_relative "user_agent"
5
5
  require_relative "proxy_settings"
6
6
  require_relative "shipping_profile"
7
7
  require_relative "dot_env"
8
+ require_relative "search_backends"
9
+ require_relative "agent_profile_url"
10
+ require_relative "index"
11
+ require_relative "handoff_target"
12
+ require_relative "handoff_only"
13
+ require_relative "offer_sources"
8
14
 
9
15
  module Portage
10
16
  module Cli
@@ -35,12 +41,30 @@ module Portage
35
41
  # process that configuration is always the unconfigured default, so
36
42
  # those four warnings were noise on every fresh install.
37
43
  def initialize(adapter_class: nil, proxy_settings: ProxySettings.new, install_doctor: InstallDoctor.new,
38
- seller: true, dot_env_path: DotEnv.loaded_path)
44
+ seller: true, dot_env_path: DotEnv.loaded_path, index_stores: Index::Store.new,
45
+ index_products: Index::ProductStore.new, known_cache: Index::KnownCache.new)
39
46
  @adapter_class = adapter_class
40
47
  @proxy_settings = proxy_settings
41
48
  @install_doctor = install_doctor
42
49
  @seller = seller
43
50
  @dot_env_path = dot_env_path
51
+ @index_stores = index_stores
52
+ @index_products = index_products
53
+ @known_cache = known_cache
54
+ end
55
+
56
+ # `portage setup` always offers its wizard on a TTY; a bare `portage
57
+ # doctor`/`configure` only offers it once, on a fresh install, rather
58
+ # than on every run of someone who's already configured most of this
59
+ # (docs/plans/buy-skill-and-local-browser.md Phase 4). Conservative on
60
+ # purpose: true only when *all three* of the foundational settings —
61
+ # no `.env` file was loaded, no shipping address, no policy at all —
62
+ # are still untouched. Running `portage index build`/`browser import`
63
+ # without ever touching shipping/policy still counts as "nothing
64
+ # configured" here; those are the wizard's own later, opt-in steps,
65
+ # not what decides whether it's worth offering in the first place.
66
+ def nothing_configured?
67
+ @dot_env_path.nil? && ShippingProfile.from_env.nil? && Portage::Ucp::Policy.load.to_h.empty?
44
68
  end
45
69
 
46
70
  def call
@@ -49,6 +73,10 @@ module Portage
49
73
  dot_env_finding,
50
74
  *seller_findings,
51
75
  decision_backend_finding,
76
+ search_backend_finding,
77
+ agent_profile_finding,
78
+ index_finding,
79
+ handoff_finding, retailer_offer_sources_finding,
52
80
  user_agent_finding,
53
81
  shipping_finding,
54
82
  proxy_finding,
@@ -123,6 +151,132 @@ module Portage
123
151
  Finding.new(check: "decision_backend", message: e.message)
124
152
  end
125
153
 
154
+ # `portage buy`/`portage find` with no URL/--store depend on a search
155
+ # backend to name candidate stores in the first place. Every install
156
+ # gets DuckDuckGo's free Instant Answer API for free, but it only
157
+ # resolves *entity* queries ("burton snowboards" → burton.com) — an
158
+ # open-ended query ("coffee", "iphone", "screens") comes back empty
159
+ # every time, regardless of what any store actually stocks (see
160
+ # SearchBackends::DuckDuckGo). That's easy to mistake for "find is
161
+ # broken" rather than "no real web search is configured", so this
162
+ # flags it up front rather than letting every URL-less search rediscover
163
+ # it one confusing empty result at a time.
164
+ def search_backend_finding
165
+ return if SearchBackends::Allowlist.new.available?
166
+
167
+ backends = SearchBackends.default
168
+ return unless SearchBackends.only_duckduckgo?(backends)
169
+
170
+ Finding.new(check: "search_backend", level: "info",
171
+ message: "Only DuckDuckGo's free Instant Answer API is active for `portage find`/" \
172
+ "`portage buy` without a URL — it resolves specific brand/product names, not " \
173
+ "open-ended search terms. Set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY+" \
174
+ "GOOGLE_CSE_CX for real web search, or list known stores in " \
175
+ "~/.portage/stores.yml.")
176
+ end
177
+
178
+ # `find`/`buy`/OfferSources::ShopifyCatalog need
179
+ # `meta.ucp-agent.profile` on every catalog/cart/checkout call
180
+ # (Transports::Http#wire_meta) — PORTAGE_AGENT_PROFILE names it, and
181
+ # AgentProfileUrl falls back to the repo's own published profile
182
+ # (docs/agent-profile.md) so a fresh install without one set still
183
+ # works well enough to try. Always info, never a warning — the
184
+ # fallback isn't broken, just worth knowing about before a real
185
+ # deployment.
186
+ def agent_profile_finding
187
+ url = AgentProfileUrl.resolve
188
+ return Finding.new(check: "agent_profile", level: "info", message: "Agent profile: #{url}") \
189
+ unless ENV.fetch("PORTAGE_AGENT_PROFILE", nil).to_s.strip.empty?
190
+
191
+ Finding.new(check: "agent_profile", level: "info",
192
+ message: "PORTAGE_AGENT_PROFILE not set — falling back to the repo's own published " \
193
+ "profile (#{url}). Run `portage generate agent-profile` and set " \
194
+ "PORTAGE_AGENT_PROFILE to your own for production use.")
195
+ end
196
+
197
+ # `portage index build/refresh` (Phase 2b) writes ~/.portage/index/
198
+ # {stores,products}.json — always info, never a warning: a fresh
199
+ # install with no index still works, `find` just has less to route
200
+ # by than one that's run `portage index build`.
201
+ #
202
+ # Also one of Phase 2c's three refresh triggers: this refreshes the
203
+ # known-stores cache (Index::KnownCache) whenever it's stale (more
204
+ # than 7 days old, which a missing cache counts as too), so a
205
+ # long-running install's cache doesn't just sit there getting older
206
+ # every time `find` happens not to trigger the lazy first-run fetch.
207
+ # One short-timeout GET per file, swallowed on any failure exactly
208
+ # like KnownCache's own #refresh! — this never turns doctor into a
209
+ # warning, just a report of whatever the fetch (or its absence)
210
+ # leaves behind.
211
+ def index_finding
212
+ refresh_known_cache_if_stale
213
+ return Finding.new(check: "index", level: "info", message: "#{index_message}\n#{known_cache_message}") \
214
+ if @index_stores.exists?
215
+
216
+ Finding.new(check: "index", level: "info",
217
+ message: "No local index yet — run `portage index build` to give `find` a list of " \
218
+ "stores/products on top of stores.yml and web search.\n#{known_cache_message}")
219
+ end
220
+
221
+ def refresh_known_cache_if_stale
222
+ @known_cache.refresh! if @known_cache.stale?
223
+ rescue StandardError
224
+ nil
225
+ end
226
+
227
+ # Phase 7: which of the five retailer OfferSources are on. Always
228
+ # info — every one of them is opt-in by design, and `find` behaves
229
+ # identically with none configured. Every one of these still ends in
230
+ # hand-off no matter how many are enabled, so this finding says that
231
+ # plainly rather than implying more keys means more automation.
232
+ def retailer_offer_sources_finding
233
+ on = OfferSources.retailers.select(&:available?).map(&:name)
234
+ message = if on.empty?
235
+ no_retailer_offer_sources_message
236
+ else
237
+ "Retailer offer sources active: #{on.join(', ')}. Every one still ends in hand-off; " \
238
+ "none can complete a purchase."
239
+ end
240
+ Finding.new(check: "retailer_offer_sources", level: "info", details: { active: on }, message: message)
241
+ end
242
+
243
+ def no_retailer_offer_sources_message
244
+ "No retailer offer sources configured (Walmart, eBay, Best Buy, Etsy, Amazon) — run `portage " \
245
+ "setup` to add keys. Every one still ends in hand-off; none can complete a purchase."
246
+ end
247
+
248
+ def index_message
249
+ age = @index_stores.oldest_verified_age
250
+ staleness = age ? "oldest entry verified #{age / 86_400} day(s) ago" : "no entries verified yet"
251
+ "Local index: #{@index_stores.all.length} store(s), #{@index_products.all.length} product(s) " \
252
+ "(#{staleness}). Run `portage index refresh` to re-verify entries older than 7 days."
253
+ end
254
+
255
+ def known_cache_message
256
+ unless @known_cache.exists?
257
+ return "Known-stores list (fetched from the repo, docs/plans/buy-skill-and-local-browser.md " \
258
+ "Phase 2c): not fetched yet — offline, or the first fetch hasn't run."
259
+ end
260
+
261
+ age = @known_cache.age
262
+ days = age ? age / 86_400 : "?"
263
+ "Known-stores list: #{@known_cache.stores.length} store(s), #{@known_cache.products.length} " \
264
+ "product(s), fetched #{days} day(s) ago."
265
+ end
266
+
267
+ # docs/plans/buy-skill-and-local-browser.md Phase 5 — always info:
268
+ # both the current hand-off target and the hand-off-only host list are
269
+ # legitimate to leave at their defaults. The disclaimer runs here as
270
+ # well as on every `handoff_only` buy report and the `buy` skill's own
271
+ # reference doc (decision 5).
272
+ def handoff_finding
273
+ target = HandoffTarget.new
274
+ hosts = HandoffOnly.new.hosts
275
+ Finding.new(check: "handoff", level: "info", details: { target: target.label, handoff_only_hosts: hosts },
276
+ message: "Hand-off target: #{target.label}. Hand-off-only hosts (#{hosts.length}): " \
277
+ "#{hosts.join(', ')}. #{HandoffOnly::LEGAL_NOTICE}")
278
+ end
279
+
126
280
  # PORTAGE_USER_AGENT / config.json's "user_agent" (UserAgent) is sent
127
281
  # as-is on every outbound request. Net::HTTP raises ArgumentError on a
128
282
  # header value containing CR/LF rather than send it, so a bad override
@@ -1,3 +1,5 @@
1
+ require "fileutils"
2
+
1
3
  module Portage
2
4
  module Cli
3
5
  # Loads `~/.portage/.env` (or the file PORTAGE_ENV_FILE names) into ENV
@@ -57,6 +59,59 @@ module Portage
57
59
  end
58
60
  end
59
61
  private_class_method :unquote
62
+
63
+ # Writes `assignments` into the `.env` file at `path` (creating
64
+ # `~/.portage` if needed), for `portage setup`'s wizard
65
+ # (docs/plans/buy-skill-and-local-browser.md Phase 4) — the only
66
+ # writer this file has had until now. A key already on a line is
67
+ # replaced in place, so a re-run never duplicates it and every other
68
+ # line (unrelated keys, comments, blank lines) is left exactly as it
69
+ # was; a key that isn't already there is appended. `assignments`
70
+ # values are always double-quoted on write (never bare) so a value
71
+ # with a space — a street address — round-trips through #parse
72
+ # correctly.
73
+ #
74
+ # Mode 0600 from the very first byte, whether or not the file existed
75
+ # before: an existing file is chmod'd 600 *before* anything is
76
+ # written into it (an existing file could already be world-readable),
77
+ # and a new file is opened with mode 0600 baked into its `File.open`
78
+ # call — never `File.write` then `File.chmod`, which briefly leaves a
79
+ # brand-new file (holding a shipping address or a search API key) at
80
+ # the process umask's permissions, e.g. 0644, before the chmod lands.
81
+ #
82
+ # @param assignments [Hash{String => String}]
83
+ # @return [String] the path written.
84
+ def self.update!(assignments, path: ENV.fetch("PORTAGE_ENV_FILE", nil) || DEFAULT_PATH)
85
+ path = File.expand_path(path)
86
+ remaining = assignments.dup
87
+ lines = existing_lines(path).map { |line| replace_assignment(line, remaining) }
88
+ remaining.each { |key, value| lines << assignment_line(key, value) }
89
+
90
+ FileUtils.mkdir_p(File.dirname(path))
91
+ File.chmod(0o600, path) if File.file?(path)
92
+ File.open(path, File::WRONLY | File::CREAT | File::TRUNC, 0o600) { |f| f.write(lines.join) }
93
+ File.chmod(0o600, path)
94
+ path
95
+ end
96
+
97
+ def self.existing_lines(path)
98
+ File.file?(path) ? File.readlines(path) : []
99
+ end
100
+ private_class_method :existing_lines
101
+
102
+ def self.replace_assignment(line, remaining)
103
+ match = LINE.match(line.chomp)
104
+ return line unless match && remaining.key?(match[1])
105
+
106
+ assignment_line(match[1], remaining.delete(match[1]))
107
+ end
108
+ private_class_method :replace_assignment
109
+
110
+ def self.assignment_line(key, value)
111
+ escaped = value.to_s.gsub("\\") { "\\\\" }.gsub('"') { "\\\"" }
112
+ "#{key}=\"#{escaped}\"\n"
113
+ end
114
+ private_class_method :assignment_line
60
115
  end
61
116
  end
62
117
  end
@@ -1,11 +1,15 @@
1
1
  require "uri"
2
+ require "securerandom"
2
3
  require "portage/ucp"
3
4
  require "portage/ucp/client"
4
5
 
5
6
  require_relative "search_backends"
7
+ require_relative "offer_sources"
8
+ require_relative "agent_profile_url"
6
9
  require_relative "probe_cache"
7
10
  require_relative "decisions"
8
11
  require_relative "user_agent"
12
+ require_relative "handoff_only"
9
13
 
10
14
  module Portage
11
15
  module Cli
@@ -29,33 +33,58 @@ module Portage
29
33
 
30
34
  # @param max_price [Integer, nil] minor units, matching the protocol's
31
35
  # own money representation — the CLI converts from major units.
32
- def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE)
36
+ # @param offer_sources [Array<#offers>, nil] a second kind of backend
37
+ # (OfferSources) that answers offers directly, with no manifest
38
+ # probe of its own — see #call. nil (the default) is
39
+ # OfferSources.default.
40
+ # @param handoff_only [HandoffOnly, nil] Tier C's host list
41
+ # (docs/plans/buy-skill-and-local-browser.md Phase 5) — nil (the
42
+ # default) is the real ~/.portage/config.json-backed one. A
43
+ # candidate on it is never probed (see #call): it still surfaces as
44
+ # a candidate, marked `handoff_only: true`, so an agent can list it
45
+ # ("Amazon also sells this") without this process ever fetching it.
46
+ def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
47
+ offer_sources: nil, handoff_only: nil)
33
48
  @query = query.to_s
34
49
  @limit = [limit, MAX_PROBES].min
35
50
  @max_price = max_price
36
51
  @backends = backends || SearchBackends.default
37
52
  @cache = cache || ProbeCache.new
38
53
  @throttle = throttle
54
+ @offer_sources = offer_sources || OfferSources.default
55
+ @handoff_only = handoff_only || HandoffOnly.new
39
56
  end
40
57
 
41
58
  def call
42
59
  return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
43
60
 
44
61
  candidates = candidate_origins
45
- return report(candidates: candidates, message: no_candidates_message) if candidates.empty?
62
+ probed, stores = probe_candidates(candidates)
63
+ sourced = source_offers
64
+ return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
46
65
 
47
- stores = probe(candidates)
48
- offers = rank(stores.flat_map { |store| offers_for(store) })
49
- report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
50
- offers: offers, message: summary(candidates, stores, offers))
66
+ offers = rank(sourced + probed.flat_map { |store| offers_for(store) }).map { |o| with_offer_ref(o) }
67
+ report(candidates: candidates, stores: store_summaries(stores), offers: offers,
68
+ message: summary(candidates, stores, offers))
51
69
  rescue Portage::Ucp::Client::MissingAgentProfileError
52
- report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
70
+ report(candidates: candidates, stores: store_summaries(stores),
53
71
  message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — each store " \
54
72
  "verifies it before answering a catalog search.")
55
73
  end
56
74
 
57
75
  private
58
76
 
77
+ # Neither the URL backends nor any OfferSource found anything to
78
+ # probe or rank — split out of #call to keep its own branching under
79
+ # the complexity budget.
80
+ def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
81
+
82
+ # A short opaque id `portage buy --offer` resolves from history, so a
83
+ # later step can point at one offer without re-sending its store,
84
+ # product id and query. Added last, after ranking, so it never
85
+ # influences the order.
86
+ def with_offer_ref(offer) = { offer_ref: "of_#{SecureRandom.hex(3)}" }.merge(offer)
87
+
59
88
  # --- Step 1: ask the backends who might sell this ---
60
89
 
61
90
  def candidate_origins
@@ -78,7 +107,44 @@ module Portage
78
107
  existing = seen[uri.host]
79
108
  return if existing && !upgradable?(existing, uri)
80
109
 
81
- seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name }
110
+ seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name,
111
+ handoff_only: @handoff_only.host?(uri.host) }
112
+ end
113
+
114
+ # Tier C candidates never reach #probe — no UCP request, ever — but
115
+ # still surface in the report's `stores`, `checkout: false`.
116
+ def handoff_only_stores(candidates)
117
+ candidates.map { |c| c.merge(checkout: false) }
118
+ end
119
+
120
+ # Splits candidates into the ones #probe actually fetches and the
121
+ # Tier C ones that never touch the network — kept out of #call to
122
+ # stay under its own complexity budget.
123
+ # @return [Array(Array<Hash>, Array<Hash>)] probed stores (the ones
124
+ # with a live `session`, so #offers_for can ask them what they
125
+ # stock), and every store for the report (probed + hand-off-only).
126
+ def probe_candidates(candidates)
127
+ probeable, deferred = candidates.partition { |c| !c[:handoff_only] }
128
+ probed = probe(probeable)
129
+ [probed, probed + handoff_only_stores(deferred)]
130
+ end
131
+
132
+ def store_summaries(stores)
133
+ stores.map { |s| s.slice(:origin, :source, :checkout, :handoff_only) }
134
+ end
135
+
136
+ # --- Step 1b: ask any OfferSources directly, no probe needed ---
137
+
138
+ # Each source already returns Find#offer-shaped hashes (store:/
139
+ # source:/checkout:/product_id:/title:/amount:/currency:/url:) and
140
+ # swallows its own failures, so nothing here needs the try/rescue
141
+ # #urls_from gives the URL backends. --max-price applies here exactly
142
+ # as it does to a probed store's offers in #offer.
143
+ def source_offers
144
+ offers = @offer_sources.flat_map do |source|
145
+ source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
146
+ end
147
+ offers.reject { |offer| over_max_price?(offer[:amount]) }
82
148
  end
83
149
 
84
150
  def upgradable?(existing, uri)
@@ -156,18 +222,21 @@ module Portage
156
222
  # before answering any call (see Transports::Http) — the own-store
157
223
  # loopback path ignores it harmlessly.
158
224
  def agent_meta
159
- { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
225
+ { agent_profile: AgentProfileUrl.resolve }
160
226
  end
161
227
 
162
228
  def offer(store, product)
163
229
  amount, currency = price_of(product)
164
- return nil if @max_price && amount && amount > @max_price
230
+ return nil if over_max_price?(amount)
165
231
 
166
232
  { store: store[:origin], source: store[:source], checkout: store[:checkout],
167
233
  product_id: field(product, "id"), title: field(product, "title"),
168
234
  amount: amount, currency: currency, url: field(product, "url") }
169
235
  end
170
236
 
237
+ # An unpriced offer stays in: no price isn't the same as too dear.
238
+ def over_max_price?(amount) = @max_price && amount && amount > @max_price
239
+
171
240
  # Buyable first, then cheapest, then unpriced (see Decisions.rank —
172
241
  # core's Support::OfferRanking, the rule portage-ucp-decision's
173
242
  # OfferRanking wraps), so an agent loop ranking its own candidate list
@@ -213,7 +282,7 @@ module Portage
213
282
  names = @backends.map(&:name)
214
283
  return no_backends_message if names.empty?
215
284
 
216
- "No candidate stores came back from #{names.join(', ')} for \"#{@query}\"."
285
+ "No candidate stores came back from #{names.join(', ')} for \"#{@query}\".#{keyed_backend_hint}"
217
286
  end
218
287
 
219
288
  def no_backends_message
@@ -221,8 +290,30 @@ module Portage
221
290
  "or list stores in ~/.portage/stores.yml."
222
291
  end
223
292
 
293
+ # DuckDuckGo's free Instant Answer API is the keyless default, but it
294
+ # only answers *entity* queries ("burton snowboards" → burton.com) —
295
+ # open-ended shopping terms like "coffee" or "iphone" resolve to
296
+ # nothing, every time, regardless of what those stores actually stock
297
+ # (see SearchBackends::DuckDuckGo's own comment). Names alone (e.g.
298
+ # `no_candidates_message`) don't say *why* nothing came back, so this
299
+ # spells out the fix rather than leaving the caller to guess whether
300
+ # it's a network problem or a backend limitation. Only fires when no
301
+ # keyed backend (Brave/Google CSE) ran alongside it — one of those
302
+ # already covered the query with real web search, so there's nothing
303
+ # to suggest.
304
+ def keyed_backend_hint
305
+ return "" unless SearchBackends.only_duckduckgo?(@backends)
306
+
307
+ " DuckDuckGo's free API only resolves specific brand/product names, not open-ended search — " \
308
+ "set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY/GOOGLE_CSE_CX for real web search " \
309
+ "(see `portage doctor`)."
310
+ end
311
+
224
312
  def summary(candidates, stores, offers)
225
- return "Found #{offers.length} offer(s) across #{stores.length} UCP store(s)." if offers.any?
313
+ # Counted from the offers, not `stores`: an OfferSource's offers
314
+ # come from stores that were never probed.
315
+ selling = offers.map { |o| o[:store] }.uniq.length
316
+ return "Found #{offers.length} offer(s) across #{selling} store(s)." if offers.any?
226
317
  return "#{stores.length} store(s) speak UCP but none stock \"#{@query}\"." if stores.any?
227
318
 
228
319
  "Checked #{candidates.length} store(s); none of them speak UCP."