portage-cli 0.7.4 → 0.8.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +580 -0
- data/README.md +322 -19
- data/exe/portage +5 -0
- data/exe/portage-console +3 -0
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/browser_import/categorize.rb +59 -0
- data/lib/portage/cli/browser_import/confirm.rb +35 -0
- data/lib/portage/cli/browser_import/domains.rb +47 -0
- data/lib/portage/cli/browser_import/filter.rb +91 -0
- data/lib/portage/cli/browser_import/importer.rb +248 -0
- data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
- data/lib/portage/cli/browser_import/prober.rb +60 -0
- data/lib/portage/cli/browser_import/profiles.rb +114 -0
- data/lib/portage/cli/browser_import/readers.rb +179 -0
- data/lib/portage/cli/browser_import/saver.rb +62 -0
- data/lib/portage/cli/browser_import/sqlite.rb +68 -0
- data/lib/portage/cli/browser_import.rb +23 -0
- data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
- data/lib/portage/cli/browser_profile/bridge.rb +120 -0
- data/lib/portage/cli/browser_profile/browsers.rb +69 -0
- data/lib/portage/cli/browser_profile/cdp.rb +67 -0
- data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
- data/lib/portage/cli/browser_profile/errors.rb +26 -0
- data/lib/portage/cli/browser_profile/launcher.rb +34 -0
- data/lib/portage/cli/browser_profile/profile.rb +93 -0
- data/lib/portage/cli/browser_profile.rb +25 -0
- data/lib/portage/cli/buy.rb +521 -29
- data/lib/portage/cli/classifier.rb +158 -0
- data/lib/portage/cli/compare.rb +3 -0
- data/lib/portage/cli/doctor.rb +195 -7
- data/lib/portage/cli/dot_env.rb +117 -0
- data/lib/portage/cli/find.rb +96 -12
- data/lib/portage/cli/handoff_agents.rb +186 -0
- data/lib/portage/cli/handoff_only.rb +94 -0
- data/lib/portage/cli/handoff_reconciler.rb +15 -1
- data/lib/portage/cli/handoff_target.rb +61 -0
- data/lib/portage/cli/index/builder.rb +335 -0
- data/lib/portage/cli/index/exporter.rb +91 -0
- data/lib/portage/cli/index/known_cache.rb +155 -0
- data/lib/portage/cli/index/product_store.rb +101 -0
- data/lib/portage/cli/index/sources/browser.rb +31 -0
- data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
- data/lib/portage/cli/index/sources/stores_file.rb +58 -0
- data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
- data/lib/portage/cli/index/sources/wikidata.rb +95 -0
- data/lib/portage/cli/index/sources.rb +44 -0
- data/lib/portage/cli/index/store.rb +109 -0
- data/lib/portage/cli/index.rb +20 -0
- data/lib/portage/cli/known_stores_url.rb +15 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/search_backends.rb +337 -12
- data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
- data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
- data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
- data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
- data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
- data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
- data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
- data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
- data/lib/portage/cli/setup_wizard.rb +74 -0
- data/lib/portage/cli/version.rb +1 -1
- data/lib/portage/cli/webmcp.rb +10 -3
- data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
- data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
- data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
- data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
- data/lib/portage/cli/webmcp_mappings.rb +84 -0
- data/lib/portage/cli.rb +528 -6
- metadata +59 -2
|
@@ -5,6 +5,10 @@ require "yaml"
|
|
|
5
5
|
require "portage/ucp"
|
|
6
6
|
require "portage/ucp/support/connection"
|
|
7
7
|
require_relative "user_agent"
|
|
8
|
+
require_relative "classifier"
|
|
9
|
+
require_relative "index/store"
|
|
10
|
+
require_relative "index/product_store"
|
|
11
|
+
require_relative "index/known_cache"
|
|
8
12
|
|
|
9
13
|
module Portage
|
|
10
14
|
module Cli
|
|
@@ -31,12 +35,23 @@ module Portage
|
|
|
31
35
|
].freeze
|
|
32
36
|
|
|
33
37
|
# Ordered cheapest/most-trusted first: your own allowlist costs no
|
|
34
|
-
# network call and needs no key,
|
|
35
|
-
#
|
|
38
|
+
# network call and needs no key, the local index next (still no
|
|
39
|
+
# network call, but untrusted — see Index's own comment), DuckDuckGo
|
|
40
|
+
# needs no key, the keyed engines only participate when their
|
|
41
|
+
# credentials are actually present.
|
|
36
42
|
def self.default
|
|
37
|
-
[Allowlist.new, DuckDuckGo.new, Brave.new, GoogleCse.new].select(&:available?)
|
|
43
|
+
[Allowlist.new, Index.new, DuckDuckGo.new, Brave.new, GoogleCse.new].select(&:available?)
|
|
38
44
|
end
|
|
39
45
|
|
|
46
|
+
# True when the only thing standing between the caller and a real web
|
|
47
|
+
# search is a missing API key — i.e. DuckDuckGo's entity-only Instant
|
|
48
|
+
# Answer API is running solo, with no allowlist and no keyed backend
|
|
49
|
+
# (Brave/Google CSE) to cover the open-ended queries it can't answer.
|
|
50
|
+
# Used to decide whether "no candidates" is worth a nudge to set
|
|
51
|
+
# BRAVE_SEARCH_API_KEY / GOOGLE_CSE_KEY+GOOGLE_CSE_CX (see Find and
|
|
52
|
+
# Doctor) rather than a plain "nothing found".
|
|
53
|
+
def self.only_duckduckgo?(backends) = backends.map(&:name) == ["duckduckgo"]
|
|
54
|
+
|
|
40
55
|
def self.get_json(uri, params: {}, headers: {})
|
|
41
56
|
uri = uri.dup
|
|
42
57
|
uri.query = URI.encode_www_form(params) unless params.empty?
|
|
@@ -63,14 +78,33 @@ module Portage
|
|
|
63
78
|
end
|
|
64
79
|
|
|
65
80
|
# Stores you've already decided you trust, listed in
|
|
66
|
-
# `~/.portage/stores.yml` (a bare YAML array of URLs
|
|
67
|
-
#
|
|
68
|
-
#
|
|
69
|
-
#
|
|
70
|
-
#
|
|
81
|
+
# `~/.portage/stores.yml` (a bare YAML array of URLs, or an array
|
|
82
|
+
# mixing in `{url:, categories: [...]}` entries once you've tagged
|
|
83
|
+
# some) or `PORTAGE_STORES` (comma-separated — the PATH-style colon
|
|
84
|
+
# can't separate values that contain `https://`).
|
|
85
|
+
#
|
|
86
|
+
# Trust stays query-independent: every entry is *always* a candidate
|
|
87
|
+
# to `find`, on the same footing whether it's tagged or not, and the
|
|
88
|
+
# store's own catalog search is still what decides whether it stocks
|
|
89
|
+
# the thing. `categories:` only changes which of these already-trusted
|
|
90
|
+
# entries get spent on *this* query's dozen probe slots — a stores.yml
|
|
91
|
+
# with fifty tagged stores across a dozen categories used to crowd out
|
|
92
|
+
# web-search candidates on every single query; #search now routes by
|
|
93
|
+
# the query's own category instead of just taking the first N.
|
|
71
94
|
class Allowlist
|
|
72
95
|
PATH = File.join(Dir.home, ".portage", "stores.yml").freeze
|
|
73
96
|
|
|
97
|
+
# At most this many of a matching category's tagged stores per
|
|
98
|
+
# `#search` call, so one heavily-tagged category can't fill every
|
|
99
|
+
# probe slot by itself.
|
|
100
|
+
PER_CATEGORY_CAP = 3
|
|
101
|
+
|
|
102
|
+
# The hard ceiling regardless of the caller's own `limit:` — mirrors
|
|
103
|
+
# Find::MAX_PROBES (duplicated rather than required, to avoid
|
|
104
|
+
# search_backends.rb depending on find.rb) since this backend can be
|
|
105
|
+
# constructed and searched outside Find too.
|
|
106
|
+
TOTAL_CAP = 12
|
|
107
|
+
|
|
74
108
|
def initialize(path: PATH, env: ENV.fetch("PORTAGE_STORES", nil))
|
|
75
109
|
@path = path
|
|
76
110
|
@env = env
|
|
@@ -80,23 +114,314 @@ module Portage
|
|
|
80
114
|
|
|
81
115
|
def available? = !entries.empty?
|
|
82
116
|
|
|
83
|
-
|
|
117
|
+
# @return [Array<String>] URLs, routed by #categorized_search.
|
|
118
|
+
def search(query, limit: 10) = categorized_search(query, [limit, TOTAL_CAP].min).map { |e| e[:url] }
|
|
119
|
+
|
|
120
|
+
# @return [Array<Hash>] every entry (url:, categories:), untouched
|
|
121
|
+
# by any query — Index::Sources::StoresFile reuses this to seed
|
|
122
|
+
# the local index from the same file, rather than re-parsing
|
|
123
|
+
# stores.yml itself.
|
|
124
|
+
def stores = entries
|
|
84
125
|
|
|
85
126
|
private
|
|
86
127
|
|
|
128
|
+
# Named entries always come first (an explicit "buy from <store>"
|
|
129
|
+
# outranks a category guess), then up to PER_CATEGORY_CAP tagged
|
|
130
|
+
# entries per matching category not already named. When no tagged
|
|
131
|
+
# entry matches any of the query's categories at all, this falls
|
|
132
|
+
# back to named entries plus every untagged entry — the pre-Phase-
|
|
133
|
+
# 2a behaviour for an all-untagged stores.yml — but never to every
|
|
134
|
+
# entry once a tagged match exists: a tagged-but-unrelated store
|
|
135
|
+
# stays excluded, which is the crowding this routing exists to fix.
|
|
136
|
+
def categorized_search(query, limit)
|
|
137
|
+
named = named_entries(query)
|
|
138
|
+
category_ids = Classifier.categories_for(query)
|
|
139
|
+
return fallback(named, limit) unless any_tagged_match?(category_ids)
|
|
140
|
+
|
|
141
|
+
remaining = [limit - named.length, 0].max
|
|
142
|
+
matched = by_category(category_ids, remaining, named)
|
|
143
|
+
(named + matched).uniq { |e| e[:url] }.first(limit)
|
|
144
|
+
end
|
|
145
|
+
|
|
146
|
+
def any_tagged_match?(category_ids)
|
|
147
|
+
category_ids.any? { |category_id| entries.any? { |e| e[:categories].include?(category_id) } }
|
|
148
|
+
end
|
|
149
|
+
|
|
150
|
+
# Named entries plus every untagged entry, capped — not every
|
|
151
|
+
# entry: a tagged store whose categories didn't match the query
|
|
152
|
+
# stays out, the same as it would once a tagged match exists.
|
|
153
|
+
def fallback(named, limit)
|
|
154
|
+
(named + entries.select { |e| e[:categories].empty? }).uniq { |e| e[:url] }.first(limit)
|
|
155
|
+
end
|
|
156
|
+
|
|
157
|
+
def by_category(category_ids, limit, exclude)
|
|
158
|
+
picked = []
|
|
159
|
+
category_ids.each do |category_id|
|
|
160
|
+
entries_for_category(category_id, exclude).each do |entry|
|
|
161
|
+
break if picked.length >= limit
|
|
162
|
+
|
|
163
|
+
picked << entry unless picked.include?(entry)
|
|
164
|
+
end
|
|
165
|
+
end
|
|
166
|
+
picked
|
|
167
|
+
end
|
|
168
|
+
|
|
169
|
+
def entries_for_category(category_id, exclude)
|
|
170
|
+
entries.select { |e| e[:categories].include?(category_id) && !exclude.include?(e) }.first(PER_CATEGORY_CAP)
|
|
171
|
+
end
|
|
172
|
+
|
|
173
|
+
# host or bare name ("shop" out of "shop.example") mentioned in the
|
|
174
|
+
# query — the escape hatch for "buy from <store>" regardless of
|
|
175
|
+
# what it's tagged, or for an untagged store the caller named.
|
|
176
|
+
def named_entries(query)
|
|
177
|
+
entries.select { |e| named?(e, query) }
|
|
178
|
+
end
|
|
179
|
+
|
|
180
|
+
def named?(entry, query)
|
|
181
|
+
host = host_of(entry[:url])
|
|
182
|
+
return false if host.empty?
|
|
183
|
+
|
|
184
|
+
label = host.sub(/\Awww\./, "").split(".").first.to_s
|
|
185
|
+
downcased = query.to_s.downcase
|
|
186
|
+
downcased.include?(host) || (label.length > 2 && downcased.include?(label))
|
|
187
|
+
end
|
|
188
|
+
|
|
189
|
+
def host_of(url)
|
|
190
|
+
URI.parse(url).host.to_s.downcase
|
|
191
|
+
rescue URI::InvalidURIError
|
|
192
|
+
""
|
|
193
|
+
end
|
|
194
|
+
|
|
87
195
|
def entries
|
|
88
|
-
@entries ||= (env_entries + file_entries).
|
|
196
|
+
@entries ||= (env_entries + file_entries).uniq { |e| e[:url] }
|
|
89
197
|
end
|
|
90
198
|
|
|
91
|
-
def env_entries
|
|
199
|
+
def env_entries
|
|
200
|
+
@env.to_s.split(",").map(&:strip).reject(&:empty?).map { |url| { url: url, categories: [] } }
|
|
201
|
+
end
|
|
92
202
|
|
|
93
203
|
def file_entries
|
|
94
204
|
return [] unless File.readable?(@path)
|
|
95
205
|
|
|
96
|
-
Array(YAML.safe_load_file(@path))
|
|
206
|
+
Array(YAML.safe_load_file(@path)).filter_map { |entry| normalize(entry) }
|
|
97
207
|
rescue StandardError
|
|
98
208
|
[]
|
|
99
209
|
end
|
|
210
|
+
|
|
211
|
+
# A bare string (the format before Phase 2a, still the common case)
|
|
212
|
+
# or a `{url:, categories: [...]}` hash — both parse, per the plan's
|
|
213
|
+
# "the file stays a bare URL list; an entry becomes tagged only when
|
|
214
|
+
# it carries categories:".
|
|
215
|
+
def normalize(entry)
|
|
216
|
+
case entry
|
|
217
|
+
when String
|
|
218
|
+
url = entry.strip
|
|
219
|
+
url.empty? ? nil : { url: url, categories: [] }
|
|
220
|
+
when Hash
|
|
221
|
+
normalize_hash(entry)
|
|
222
|
+
end
|
|
223
|
+
end
|
|
224
|
+
|
|
225
|
+
def normalize_hash(entry)
|
|
226
|
+
entry = entry.transform_keys(&:to_s)
|
|
227
|
+
url = entry["url"].to_s.strip
|
|
228
|
+
return nil if url.empty?
|
|
229
|
+
|
|
230
|
+
{ url: url, categories: Array(entry["categories"]).map(&:to_s) }
|
|
231
|
+
end
|
|
232
|
+
end
|
|
233
|
+
|
|
234
|
+
# `~/.portage/index/stores.json` — origins `portage index build`
|
|
235
|
+
# found and verified itself (docs/plans/buy-skill-and-local-browser.md
|
|
236
|
+
# Phase 2b). Unlike Allowlist, this data is **untrusted**: nothing
|
|
237
|
+
# here was ever typed in by the user, so an entry never skips a probe
|
|
238
|
+
# (Find still re-verifies it through ProbeCache like any other
|
|
239
|
+
# candidate URL) and never becomes a `merchant_allowlist`/`--yes`
|
|
240
|
+
# shortcut — it's just another URL a search backend handed back,
|
|
241
|
+
# ranked below Allowlist and above the web-search backends in
|
|
242
|
+
# SearchBackends.default (search_backends_spec.rb has specs proving
|
|
243
|
+
# both non-shortcuts).
|
|
244
|
+
#
|
|
245
|
+
# Routes the same way Allowlist routes a tagged stores.yml (up to
|
|
246
|
+
# PER_CATEGORY_CAP per matching category, TOTAL_CAP overall), plus
|
|
247
|
+
# one thing stores.yml can't do: match a query against a *product* the
|
|
248
|
+
# index has seen (by name or GTIN) and put that product's own stores
|
|
249
|
+
# first, ahead of a category guess. A store the query names outright
|
|
250
|
+
# comes next (Phase 3 — the only route an uncategorised
|
|
251
|
+
# `portage browser import` domain ever gets).
|
|
252
|
+
#
|
|
253
|
+
# Phase 2c: also draws on the repo's own known-stores cache
|
|
254
|
+
# (Index::KnownCache) — fetched lazily the first time this backend is
|
|
255
|
+
# asked for anything and no cache exists yet — merged *underneath*
|
|
256
|
+
# the user's own Store/ProductStore entries: a known entry only shows
|
|
257
|
+
# up when the user's own index doesn't already have that origin/key,
|
|
258
|
+
# so a local `index add`/`index build` finding always wins. Still the
|
|
259
|
+
# same untrusted posture either way — an offer built from either
|
|
260
|
+
# source carries `source: "index"`, never a merchant_allowlist/--yes
|
|
261
|
+
# shortcut (see this class's own header comment).
|
|
262
|
+
class Index
|
|
263
|
+
PER_CATEGORY_CAP = 3
|
|
264
|
+
TOTAL_CAP = 12
|
|
265
|
+
|
|
266
|
+
def initialize(stores: Portage::Cli::Index::Store.new, products: Portage::Cli::Index::ProductStore.new,
|
|
267
|
+
known: Portage::Cli::Index::KnownCache.new)
|
|
268
|
+
@stores = stores
|
|
269
|
+
@products = products
|
|
270
|
+
@known = known
|
|
271
|
+
end
|
|
272
|
+
|
|
273
|
+
def name = "index"
|
|
274
|
+
|
|
275
|
+
def available? = !store_entries.empty? || !product_entries.empty?
|
|
276
|
+
|
|
277
|
+
def search(query, limit: 10)
|
|
278
|
+
return [] if store_entries.empty? && product_entries.empty?
|
|
279
|
+
|
|
280
|
+
product_origins = origins_for_products(query)
|
|
281
|
+
named = named_origins(query, exclude: product_origins)
|
|
282
|
+
category_origins = origins_for_categories(Classifier.categories_for(query), exclude: product_origins + named)
|
|
283
|
+
(product_origins + named + category_origins).uniq.first([limit, TOTAL_CAP].min)
|
|
284
|
+
end
|
|
285
|
+
|
|
286
|
+
private
|
|
287
|
+
|
|
288
|
+
def store_entries
|
|
289
|
+
@store_entries ||= merge_under_own(@stores.all, known_stores, key: "origin")
|
|
290
|
+
end
|
|
291
|
+
|
|
292
|
+
def product_entries
|
|
293
|
+
@product_entries ||= merge_under_own(@products.all, known_products, key: "key")
|
|
294
|
+
end
|
|
295
|
+
|
|
296
|
+
# `entry[key]` is always present on both sides — Store#upsert seeds
|
|
297
|
+
# "origin" and ProductStore#upsert seeds "key" the same way on a
|
|
298
|
+
# brand-new entry, and KnownCache's own fetch just carries whatever
|
|
299
|
+
# `portage index build --export` wrote, in the same schema.
|
|
300
|
+
def merge_under_own(own, known, key:)
|
|
301
|
+
own_keys = own.map { |e| e[key] }
|
|
302
|
+
own + known.reject { |e| own_keys.include?(e[key]) }
|
|
303
|
+
end
|
|
304
|
+
|
|
305
|
+
def known_stores
|
|
306
|
+
ensure_known_cache!
|
|
307
|
+
@known.stores.values
|
|
308
|
+
end
|
|
309
|
+
|
|
310
|
+
def known_products
|
|
311
|
+
ensure_known_cache!
|
|
312
|
+
@known.products.values
|
|
313
|
+
end
|
|
314
|
+
|
|
315
|
+
# Fetched at most once per instance — a cache miss found here means
|
|
316
|
+
# "still no cache", not "try again this call".
|
|
317
|
+
def ensure_known_cache!
|
|
318
|
+
return if @known_cache_checked
|
|
319
|
+
|
|
320
|
+
@known_cache_checked = true
|
|
321
|
+
@known.fetch_if_missing!
|
|
322
|
+
end
|
|
323
|
+
|
|
324
|
+
def origins_for_products(query)
|
|
325
|
+
matches = product_entries.select { |product| product_matches?(product, query) }
|
|
326
|
+
matches.flat_map { |product| Array(product["stores"]).map { |s| s["origin"] } }.uniq
|
|
327
|
+
end
|
|
328
|
+
|
|
329
|
+
# Whole-word matching, built on the same Classifier.tokenize/
|
|
330
|
+
# .word_match? a title/query is classified into categories with
|
|
331
|
+
# (docs/plans/buy-skill-and-local-browser.md Phase 2a) — a plain
|
|
332
|
+
# substring check goes both ways regardless of word boundaries
|
|
333
|
+
# ("tea" inside "steam"/"teak", "bag" inside "bagel"), which is
|
|
334
|
+
# exactly the false-positive class 2a already fixed for category
|
|
335
|
+
# keywords and this backend was still exposed to.
|
|
336
|
+
#
|
|
337
|
+
# A match is either every one of the query's tokens found among the
|
|
338
|
+
# title/alias's own tokens (a short query naming a longer title,
|
|
339
|
+
# e.g. "hiking boots" -> "Men's Hiking Boot"), or the reverse (a
|
|
340
|
+
# longer query that names the whole title/alias as a phrase, e.g.
|
|
341
|
+
# "where can I buy a trail boot"). An empty/whitespace-only query
|
|
342
|
+
# tokenizes to nothing and matches no product.
|
|
343
|
+
def product_matches?(product, query)
|
|
344
|
+
return true if gtin_match?(product, query)
|
|
345
|
+
|
|
346
|
+
# Checked after GTIN, not before: a purely numeric query (the
|
|
347
|
+
# normal shape of a GTIN) tokenizes to nothing at all —
|
|
348
|
+
# Classifier.tokenize splits on runs of non-alpha characters — so
|
|
349
|
+
# gating on "any tokens" first would refuse a valid barcode
|
|
350
|
+
# lookup before #gtin_match? ever got to compare it.
|
|
351
|
+
query_tokens = Classifier.tokenize(query)
|
|
352
|
+
return false if query_tokens.empty?
|
|
353
|
+
|
|
354
|
+
[product["title"], *Array(product["aliases"])].compact.any? { |text| title_matches?(query_tokens, text) }
|
|
355
|
+
end
|
|
356
|
+
|
|
357
|
+
# Exact match against the whole (stripped, downcased) query, never a
|
|
358
|
+
# substring — a GTIN is a barcode, not a word Classifier.tokenize
|
|
359
|
+
# would even keep (it splits on runs of non-alpha characters, so a
|
|
360
|
+
# purely numeric query tokenizes to nothing).
|
|
361
|
+
def gtin_match?(product, query)
|
|
362
|
+
gtin = product["gtin"]
|
|
363
|
+
!gtin.to_s.empty? && query.to_s.strip.downcase == gtin.to_s.downcase
|
|
364
|
+
end
|
|
365
|
+
|
|
366
|
+
def title_matches?(query_tokens, text)
|
|
367
|
+
title_tokens = Classifier.tokenize(text)
|
|
368
|
+
return false if title_tokens.empty?
|
|
369
|
+
|
|
370
|
+
all_match?(query_tokens, title_tokens) || all_match?(title_tokens, query_tokens)
|
|
371
|
+
end
|
|
372
|
+
|
|
373
|
+
def all_match?(these, those)
|
|
374
|
+
these.all? { |a| those.any? { |b| Classifier.word_match?(a, b) } }
|
|
375
|
+
end
|
|
376
|
+
|
|
377
|
+
# A store the query names outright — its host ("allbirds.com") or
|
|
378
|
+
# its bare name as a whole word ("buy from allbirds") — whether or
|
|
379
|
+
# not it carries categories. This is the only way an uncategorised
|
|
380
|
+
# entry (a `portage browser import` domain whose titles matched no
|
|
381
|
+
# category — Phase 3) is ever routed: by name, never for a generic
|
|
382
|
+
# query.
|
|
383
|
+
def named_origins(query, exclude:)
|
|
384
|
+
tokens = Classifier.tokenize(query)
|
|
385
|
+
text = query.to_s.downcase
|
|
386
|
+
store_entries.filter_map do |entry|
|
|
387
|
+
origin = entry["origin"]
|
|
388
|
+
origin if !exclude.include?(origin) && names_store?(origin, text, tokens)
|
|
389
|
+
end
|
|
390
|
+
end
|
|
391
|
+
|
|
392
|
+
def names_store?(origin, text, tokens)
|
|
393
|
+
host = URI.parse(origin.to_s).host.to_s.downcase.delete_prefix("www.")
|
|
394
|
+
return false if host.empty?
|
|
395
|
+
|
|
396
|
+
text.include?(host) || tokens.include?(host.split(".").first)
|
|
397
|
+
rescue URI::InvalidURIError
|
|
398
|
+
false
|
|
399
|
+
end
|
|
400
|
+
|
|
401
|
+
def origins_for_categories(category_ids, exclude:)
|
|
402
|
+
picked = []
|
|
403
|
+
category_ids.each do |category_id|
|
|
404
|
+
entries_for_category(category_id, exclude + picked).each do |origin|
|
|
405
|
+
break if picked.length >= TOTAL_CAP
|
|
406
|
+
|
|
407
|
+
picked << origin
|
|
408
|
+
end
|
|
409
|
+
end
|
|
410
|
+
picked
|
|
411
|
+
end
|
|
412
|
+
|
|
413
|
+
# Ranked by this category's own weight (how many of the store's
|
|
414
|
+
# products the Classifier put there — see Index::Builder's
|
|
415
|
+
# #merge_categories), highest first, before PER_CATEGORY_CAP cuts
|
|
416
|
+
# it off, so a store barely tagged into a category doesn't take a
|
|
417
|
+
# slot from one the Classifier weighted heavily into it.
|
|
418
|
+
def entries_for_category(category_id, exclude)
|
|
419
|
+
matches = store_entries.select do |e|
|
|
420
|
+
Array(e["categories"]&.keys).include?(category_id) && !exclude.include?(e["origin"])
|
|
421
|
+
end
|
|
422
|
+
ranked = matches.sort_by { |e| -e["categories"][category_id].to_i }
|
|
423
|
+
ranked.first(PER_CATEGORY_CAP).map { |e| e["origin"] }
|
|
424
|
+
end
|
|
100
425
|
end
|
|
101
426
|
|
|
102
427
|
# DuckDuckGo's Instant Answer API — official, documented, no key
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
require "io/console"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class SetupWizard
|
|
6
|
+
# The one place every step reads a question and prints a line — so
|
|
7
|
+
# "Enter/blank keeps the current value", "y/N confirm" and "never
|
|
8
|
+
# echo a secret" (docs/plans/buy-skill-and-local-browser.md Phase 4)
|
|
9
|
+
# are each implemented once instead of per step. Defaults to the real
|
|
10
|
+
# $stdin/$stdout, like WebmcpMappingConfirm/BrowserImport::Confirm —
|
|
11
|
+
# a spec swaps in a fake input/output (or stubs the real $stdin the
|
|
12
|
+
# same way cli_spec.rb already does for `browser import`).
|
|
13
|
+
class Prompt
|
|
14
|
+
def initialize(input: $stdin, output: $stdout)
|
|
15
|
+
@input = input
|
|
16
|
+
@output = output
|
|
17
|
+
end
|
|
18
|
+
|
|
19
|
+
def say(text) = @output.puts(text)
|
|
20
|
+
|
|
21
|
+
# @return [Boolean] `default` when the answer is blank; otherwise
|
|
22
|
+
# whether the answer starts with "y".
|
|
23
|
+
def confirm(question, default: true)
|
|
24
|
+
@output.print("#{question} [#{default ? 'Y/n' : 'y/N'}] ")
|
|
25
|
+
@output.flush
|
|
26
|
+
answer = @input.gets.to_s.strip.downcase
|
|
27
|
+
return default if answer.empty?
|
|
28
|
+
|
|
29
|
+
answer.start_with?("y")
|
|
30
|
+
end
|
|
31
|
+
|
|
32
|
+
# @return [String, nil] nil for a blank answer — Enter keeps
|
|
33
|
+
# whatever's already set, and the step decides what that means.
|
|
34
|
+
def ask(question, hint: nil)
|
|
35
|
+
@output.print("#{question}#{" (#{hint})" if hint}: ")
|
|
36
|
+
@output.flush
|
|
37
|
+
blank_to_nil(@input.gets.to_s.chomp)
|
|
38
|
+
end
|
|
39
|
+
|
|
40
|
+
# Same contract as #ask, but the answer is never echoed to the
|
|
41
|
+
# terminal: IO#noecho when `@input` is a real, attached tty (a
|
|
42
|
+
# spec's stubbed-but-otherwise-real $stdin isn't, so this falls
|
|
43
|
+
# back to a plain read there, same as a piped stdin in production).
|
|
44
|
+
def ask_secret(question, hint: nil)
|
|
45
|
+
@output.print("#{question}#{" (#{hint})" if hint}: ")
|
|
46
|
+
@output.flush
|
|
47
|
+
answer = read_secret
|
|
48
|
+
@output.puts
|
|
49
|
+
blank_to_nil(answer.to_s.chomp)
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
private
|
|
53
|
+
|
|
54
|
+
def blank_to_nil(text)
|
|
55
|
+
text = text.strip
|
|
56
|
+
text.empty? ? nil : text
|
|
57
|
+
end
|
|
58
|
+
|
|
59
|
+
def read_secret
|
|
60
|
+
@input.noecho(&:gets).to_s
|
|
61
|
+
rescue Errno::ENOTTY, NoMethodError, IOError
|
|
62
|
+
@input.gets.to_s
|
|
63
|
+
end
|
|
64
|
+
end
|
|
65
|
+
end
|
|
66
|
+
end
|
|
67
|
+
end
|
|
@@ -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
|