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.
- checksums.yaml +4 -4
- data/CHANGELOG.md +559 -0
- data/README.md +266 -5
- 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 +155 -1
- data/lib/portage/cli/dot_env.rb +55 -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 +525 -5
- 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
|
data/lib/portage/cli/compare.rb
CHANGED
|
@@ -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
|
data/lib/portage/cli/doctor.rb
CHANGED
|
@@ -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
|
data/lib/portage/cli/dot_env.rb
CHANGED
|
@@ -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
|
data/lib/portage/cli/find.rb
CHANGED
|
@@ -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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
|
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:
|
|
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
|
|
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
|
-
|
|
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."
|