portage-cli 0.7.5 → 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 (71) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +559 -0
  3. data/README.md +266 -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/browser_import/categorize.rb +59 -0
  7. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  8. data/lib/portage/cli/browser_import/domains.rb +47 -0
  9. data/lib/portage/cli/browser_import/filter.rb +91 -0
  10. data/lib/portage/cli/browser_import/importer.rb +248 -0
  11. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  12. data/lib/portage/cli/browser_import/prober.rb +60 -0
  13. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  14. data/lib/portage/cli/browser_import/readers.rb +179 -0
  15. data/lib/portage/cli/browser_import/saver.rb +62 -0
  16. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  17. data/lib/portage/cli/browser_import.rb +23 -0
  18. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  19. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  20. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  21. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  22. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  23. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  24. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  25. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  26. data/lib/portage/cli/browser_profile.rb +25 -0
  27. data/lib/portage/cli/buy.rb +521 -29
  28. data/lib/portage/cli/classifier.rb +158 -0
  29. data/lib/portage/cli/compare.rb +3 -0
  30. data/lib/portage/cli/doctor.rb +155 -1
  31. data/lib/portage/cli/dot_env.rb +55 -0
  32. data/lib/portage/cli/find.rb +96 -12
  33. data/lib/portage/cli/handoff_agents.rb +186 -0
  34. data/lib/portage/cli/handoff_only.rb +94 -0
  35. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  36. data/lib/portage/cli/handoff_target.rb +61 -0
  37. data/lib/portage/cli/index/builder.rb +335 -0
  38. data/lib/portage/cli/index/exporter.rb +91 -0
  39. data/lib/portage/cli/index/known_cache.rb +155 -0
  40. data/lib/portage/cli/index/product_store.rb +101 -0
  41. data/lib/portage/cli/index/sources/browser.rb +31 -0
  42. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  43. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  44. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  45. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  46. data/lib/portage/cli/index/sources.rb +44 -0
  47. data/lib/portage/cli/index/store.rb +109 -0
  48. data/lib/portage/cli/index.rb +20 -0
  49. data/lib/portage/cli/known_stores_url.rb +15 -0
  50. data/lib/portage/cli/offer_sources.rb +460 -0
  51. data/lib/portage/cli/payment_methods.rb +24 -3
  52. data/lib/portage/cli/search_backends.rb +337 -12
  53. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  54. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  55. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  56. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  57. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  58. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  59. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  60. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  61. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  62. data/lib/portage/cli/setup_wizard.rb +74 -0
  63. data/lib/portage/cli/version.rb +1 -1
  64. data/lib/portage/cli/webmcp.rb +10 -3
  65. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  66. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  67. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  68. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  69. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  70. data/lib/portage/cli.rb +525 -5
  71. metadata +58 -2
@@ -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
@@ -3,9 +3,12 @@ require "portage/ucp"
3
3
  require "portage/ucp/client"
4
4
 
5
5
  require_relative "search_backends"
6
+ require_relative "offer_sources"
7
+ require_relative "agent_profile_url"
6
8
  require_relative "probe_cache"
7
9
  require_relative "decisions"
8
10
  require_relative "user_agent"
11
+ require_relative "handoff_only"
9
12
 
10
13
  module Portage
11
14
  module Cli
@@ -29,33 +32,52 @@ module Portage
29
32
 
30
33
  # @param max_price [Integer, nil] minor units, matching the protocol's
31
34
  # 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)
35
+ # @param offer_sources [Array<#offers>, nil] a second kind of backend
36
+ # (OfferSources) that answers offers directly, with no manifest
37
+ # probe of its own — see #call. nil (the default) is
38
+ # OfferSources.default.
39
+ # @param handoff_only [HandoffOnly, nil] Tier C's host list
40
+ # (docs/plans/buy-skill-and-local-browser.md Phase 5) — nil (the
41
+ # default) is the real ~/.portage/config.json-backed one. A
42
+ # candidate on it is never probed (see #call): it still surfaces as
43
+ # a candidate, marked `handoff_only: true`, so an agent can list it
44
+ # ("Amazon also sells this") without this process ever fetching it.
45
+ def initialize(query:, limit: MAX_PROBES, max_price: nil, backends: nil, cache: nil, throttle: THROTTLE,
46
+ offer_sources: nil, handoff_only: nil)
33
47
  @query = query.to_s
34
48
  @limit = [limit, MAX_PROBES].min
35
49
  @max_price = max_price
36
50
  @backends = backends || SearchBackends.default
37
51
  @cache = cache || ProbeCache.new
38
52
  @throttle = throttle
53
+ @offer_sources = offer_sources || OfferSources.default
54
+ @handoff_only = handoff_only || HandoffOnly.new
39
55
  end
40
56
 
41
57
  def call
42
58
  return report(message: "Nothing to search for — pass --query.") if @query.strip.empty?
43
59
 
44
60
  candidates = candidate_origins
45
- return report(candidates: candidates, message: no_candidates_message) if candidates.empty?
61
+ probed, stores = probe_candidates(candidates)
62
+ sourced = source_offers
63
+ return report(candidates: candidates, message: no_candidates_message) if nothing_to_go_on?(candidates, sourced)
46
64
 
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))
65
+ offers = rank(sourced + probed.flat_map { |store| offers_for(store) })
66
+ report(candidates: candidates, stores: store_summaries(stores), offers: offers,
67
+ message: summary(candidates, stores, offers))
51
68
  rescue Portage::Ucp::Client::MissingAgentProfileError
52
- report(candidates: candidates, stores: stores.map { |s| s.slice(:origin, :source, :checkout) },
69
+ report(candidates: candidates, stores: store_summaries(stores),
53
70
  message: "Set PORTAGE_AGENT_PROFILE to a URL that describes this agent — each store " \
54
71
  "verifies it before answering a catalog search.")
55
72
  end
56
73
 
57
74
  private
58
75
 
76
+ # Neither the URL backends nor any OfferSource found anything to
77
+ # probe or rank — split out of #call to keep its own branching under
78
+ # the complexity budget.
79
+ def nothing_to_go_on?(candidates, sourced) = candidates.empty? && sourced.empty?
80
+
59
81
  # --- Step 1: ask the backends who might sell this ---
60
82
 
61
83
  def candidate_origins
@@ -78,7 +100,44 @@ module Portage
78
100
  existing = seen[uri.host]
79
101
  return if existing && !upgradable?(existing, uri)
80
102
 
81
- seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name }
103
+ seen[uri.host] = { origin: origin_of(uri), source: existing ? existing[:source] : backend.name,
104
+ handoff_only: @handoff_only.host?(uri.host) }
105
+ end
106
+
107
+ # Tier C candidates never reach #probe — no UCP request, ever — but
108
+ # still surface in the report's `stores`, `checkout: false`.
109
+ def handoff_only_stores(candidates)
110
+ candidates.map { |c| c.merge(checkout: false) }
111
+ end
112
+
113
+ # Splits candidates into the ones #probe actually fetches and the
114
+ # Tier C ones that never touch the network — kept out of #call to
115
+ # stay under its own complexity budget.
116
+ # @return [Array(Array<Hash>, Array<Hash>)] probed stores (the ones
117
+ # with a live `session`, so #offers_for can ask them what they
118
+ # stock), and every store for the report (probed + hand-off-only).
119
+ def probe_candidates(candidates)
120
+ probeable, deferred = candidates.partition { |c| !c[:handoff_only] }
121
+ probed = probe(probeable)
122
+ [probed, probed + handoff_only_stores(deferred)]
123
+ end
124
+
125
+ def store_summaries(stores)
126
+ stores.map { |s| s.slice(:origin, :source, :checkout, :handoff_only) }
127
+ end
128
+
129
+ # --- Step 1b: ask any OfferSources directly, no probe needed ---
130
+
131
+ # Each source already returns Find#offer-shaped hashes (store:/
132
+ # source:/checkout:/product_id:/title:/amount:/currency:/url:) and
133
+ # swallows its own failures, so nothing here needs the try/rescue
134
+ # #urls_from gives the URL backends. --max-price applies here exactly
135
+ # as it does to a probed store's offers in #offer.
136
+ def source_offers
137
+ offers = @offer_sources.flat_map do |source|
138
+ source.offers(@query, limit: PER_STORE_RESULTS, context: BuyerContext.from_env)
139
+ end
140
+ offers.reject { |offer| over_max_price?(offer[:amount]) }
82
141
  end
83
142
 
84
143
  def upgradable?(existing, uri)
@@ -156,18 +215,21 @@ module Portage
156
215
  # before answering any call (see Transports::Http) — the own-store
157
216
  # loopback path ignores it harmlessly.
158
217
  def agent_meta
159
- { agent_profile: ENV.fetch("PORTAGE_AGENT_PROFILE", nil) }
218
+ { agent_profile: AgentProfileUrl.resolve }
160
219
  end
161
220
 
162
221
  def offer(store, product)
163
222
  amount, currency = price_of(product)
164
- return nil if @max_price && amount && amount > @max_price
223
+ return nil if over_max_price?(amount)
165
224
 
166
225
  { store: store[:origin], source: store[:source], checkout: store[:checkout],
167
226
  product_id: field(product, "id"), title: field(product, "title"),
168
227
  amount: amount, currency: currency, url: field(product, "url") }
169
228
  end
170
229
 
230
+ # An unpriced offer stays in: no price isn't the same as too dear.
231
+ def over_max_price?(amount) = @max_price && amount && amount > @max_price
232
+
171
233
  # Buyable first, then cheapest, then unpriced (see Decisions.rank —
172
234
  # core's Support::OfferRanking, the rule portage-ucp-decision's
173
235
  # OfferRanking wraps), so an agent loop ranking its own candidate list
@@ -213,7 +275,7 @@ module Portage
213
275
  names = @backends.map(&:name)
214
276
  return no_backends_message if names.empty?
215
277
 
216
- "No candidate stores came back from #{names.join(', ')} for \"#{@query}\"."
278
+ "No candidate stores came back from #{names.join(', ')} for \"#{@query}\".#{keyed_backend_hint}"
217
279
  end
218
280
 
219
281
  def no_backends_message
@@ -221,8 +283,30 @@ module Portage
221
283
  "or list stores in ~/.portage/stores.yml."
222
284
  end
223
285
 
286
+ # DuckDuckGo's free Instant Answer API is the keyless default, but it
287
+ # only answers *entity* queries ("burton snowboards" → burton.com) —
288
+ # open-ended shopping terms like "coffee" or "iphone" resolve to
289
+ # nothing, every time, regardless of what those stores actually stock
290
+ # (see SearchBackends::DuckDuckGo's own comment). Names alone (e.g.
291
+ # `no_candidates_message`) don't say *why* nothing came back, so this
292
+ # spells out the fix rather than leaving the caller to guess whether
293
+ # it's a network problem or a backend limitation. Only fires when no
294
+ # keyed backend (Brave/Google CSE) ran alongside it — one of those
295
+ # already covered the query with real web search, so there's nothing
296
+ # to suggest.
297
+ def keyed_backend_hint
298
+ return "" unless SearchBackends.only_duckduckgo?(@backends)
299
+
300
+ " DuckDuckGo's free API only resolves specific brand/product names, not open-ended search — " \
301
+ "set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY/GOOGLE_CSE_CX for real web search " \
302
+ "(see `portage doctor`)."
303
+ end
304
+
224
305
  def summary(candidates, stores, offers)
225
- return "Found #{offers.length} offer(s) across #{stores.length} UCP store(s)." if offers.any?
306
+ # Counted from the offers, not `stores`: an OfferSource's offers
307
+ # come from stores that were never probed.
308
+ selling = offers.map { |o| o[:store] }.uniq.length
309
+ return "Found #{offers.length} offer(s) across #{selling} store(s)." if offers.any?
226
310
  return "#{stores.length} store(s) speak UCP but none stock \"#{@query}\"." if stores.any?
227
311
 
228
312
  "Checked #{candidates.length} store(s); none of them speak UCP."