portage-cli 0.7.5 → 0.9.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- checksums.yaml +4 -4
- data/CHANGELOG.md +616 -0
- data/README.md +302 -5
- data/known-stores/categories.yml +1263 -0
- data/lib/portage/cli/agent_profile_url.rb +30 -0
- data/lib/portage/cli/approval_policy.rb +56 -0
- data/lib/portage/cli/approve.rb +135 -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_opener.rb +38 -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 +559 -29
- data/lib/portage/cli/checkout_handoff.rb +5 -24
- 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 +103 -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/history.rb +47 -2
- data/lib/portage/cli/human_prompt.rb +118 -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/money.rb +18 -0
- data/lib/portage/cli/offer_choice.rb +38 -0
- data/lib/portage/cli/offer_sources.rb +460 -0
- data/lib/portage/cli/payment_methods.rb +24 -3
- data/lib/portage/cli/pick.rb +167 -0
- data/lib/portage/cli/product_page.rb +84 -0
- data/lib/portage/cli/quotes.rb +82 -0
- 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 +930 -43
- metadata +67 -2
|
@@ -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
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class SetupWizard
|
|
4
|
+
module Steps
|
|
5
|
+
# Step 5 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
6
|
+
# `portage index build` (Phases 2b-2c). Opt-in and off by default —
|
|
7
|
+
# it's a live network sweep of Shopify's global catalog, one query
|
|
8
|
+
# per taxonomy node, which is a lot to run unasked inside a wizard.
|
|
9
|
+
# Delegates to Cli.run_index_build so the command's own sources,
|
|
10
|
+
# probe cap and progress output are exactly what a direct
|
|
11
|
+
# `portage index build` would give.
|
|
12
|
+
class IndexBuild
|
|
13
|
+
def initialize(prompt:) = @prompt = prompt
|
|
14
|
+
|
|
15
|
+
def title = "Local store index"
|
|
16
|
+
def default_yes? = false
|
|
17
|
+
|
|
18
|
+
def call
|
|
19
|
+
@prompt.say("Builds ~/.portage/index/{stores,products}.json — stores and products `find` can " \
|
|
20
|
+
"route by on top of stores.yml, the known-stores list this repo already fetches, " \
|
|
21
|
+
"and web search. Takes a little while: one query per product-category node against " \
|
|
22
|
+
"Shopify's catalog, verifying each new store with a single /.well-known/ucp probe.")
|
|
23
|
+
return unless @prompt.confirm("Run `portage index build` now?", default: false)
|
|
24
|
+
|
|
25
|
+
Portage::Cli.send(:run_index_build, [], refresh: false)
|
|
26
|
+
end
|
|
27
|
+
end
|
|
28
|
+
end
|
|
29
|
+
end
|
|
30
|
+
end
|
|
31
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
class SetupWizard
|
|
4
|
+
module Steps
|
|
5
|
+
# Step 6 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
6
|
+
# spending policy caps (Portage::Ucp::PolicyGuard), via `portage
|
|
7
|
+
# policy set` — reused, not reimplemented, so the actual cap/
|
|
8
|
+
# allowlist logic (Cli.set_policy_cap/set_policy_allowlist) has
|
|
9
|
+
# exactly one place it lives.
|
|
10
|
+
class Policy
|
|
11
|
+
def initialize(prompt:) = @prompt = prompt
|
|
12
|
+
|
|
13
|
+
def title = "Spending policy caps"
|
|
14
|
+
def default_yes? = true
|
|
15
|
+
|
|
16
|
+
def call
|
|
17
|
+
@prompt.say("Checked before every `portage buy --yes` checkout completes. Enter skips a " \
|
|
18
|
+
"question you don't want to answer right now — nothing here is required.")
|
|
19
|
+
args = cap_args + allowlist_args
|
|
20
|
+
return @prompt.say("No policy changes made.") if args.empty?
|
|
21
|
+
|
|
22
|
+
Portage::Cli.send(:run_policy_set, args)
|
|
23
|
+
end
|
|
24
|
+
|
|
25
|
+
private
|
|
26
|
+
|
|
27
|
+
def cap_args
|
|
28
|
+
major = @prompt.ask("Per-transaction spending cap", hint: "major units, e.g. 200 for $200; Enter to skip")
|
|
29
|
+
return [] unless major
|
|
30
|
+
|
|
31
|
+
parsed = Float(major, exception: false)
|
|
32
|
+
return not_a_number unless parsed&.positive?
|
|
33
|
+
|
|
34
|
+
currency = @prompt.ask("Currency for that cap", hint: "e.g. USD")
|
|
35
|
+
return no_currency if currency.nil?
|
|
36
|
+
|
|
37
|
+
# Cli.to_minor_units is private — reused via `send` rather than
|
|
38
|
+
# a second implementation of major-to-minor-units conversion.
|
|
39
|
+
["--per-transaction-cap", Portage::Cli.send(:to_minor_units, parsed).to_s, "--currency", currency]
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
def not_a_number
|
|
43
|
+
@prompt.say("Not a number — cap not set.")
|
|
44
|
+
[]
|
|
45
|
+
end
|
|
46
|
+
|
|
47
|
+
def no_currency
|
|
48
|
+
@prompt.say("No currency given — cap not set.")
|
|
49
|
+
[]
|
|
50
|
+
end
|
|
51
|
+
|
|
52
|
+
def allowlist_args
|
|
53
|
+
hosts = @prompt.ask("Merchant allowlist hosts to add", hint: "comma-separated, Enter to skip")
|
|
54
|
+
Array(hosts&.split(",")).map(&:strip).reject(&:empty?).flat_map { |host| ["--allow", host] }
|
|
55
|
+
end
|
|
56
|
+
end
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
require_relative "../../dot_env"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class SetupWizard
|
|
6
|
+
module Steps
|
|
7
|
+
# Phase 7 (docs/plans/buy-skill-and-local-browser.md): the five
|
|
8
|
+
# official retailer offer sources (OfferSources) are each opt-in —
|
|
9
|
+
# this step is where their keys get written, same as SearchKeys
|
|
10
|
+
# does for Brave/Google CSE. Every one of these still ends in
|
|
11
|
+
# hand-off (see HandoffOnly / OfferSources.retail_handoff_host?):
|
|
12
|
+
# setting a key here only makes `find` show more real offers, it
|
|
13
|
+
# never lets `portage buy` complete a purchase at any of these
|
|
14
|
+
# retailers.
|
|
15
|
+
class RetailerKeys
|
|
16
|
+
FIELDS = {
|
|
17
|
+
"WALMART_AFFILIATE_API_KEY" => "Walmart Affiliate API key",
|
|
18
|
+
"EBAY_BROWSE_ACCESS_TOKEN" => "eBay Browse API access token (Buy It Now search only)",
|
|
19
|
+
"BESTBUY_API_KEY" => "Best Buy Products API key",
|
|
20
|
+
"ETSY_LISTINGS_API_KEY" => "Etsy Open API v3 key (buyer-side listing search)",
|
|
21
|
+
"AMAZON_CREATORS_ACCESS_TOKEN" => "Amazon Creators API access token"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Retailer offer sources"
|
|
27
|
+
def default_yes? = false
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("Optional: official buyer-side APIs for Walmart, eBay (Buy It Now only), Best " \
|
|
31
|
+
"Buy, Etsy and Amazon. Each needs its own key from that retailer's developer " \
|
|
32
|
+
"program — skip any you don't have. None of these ever completes a purchase: " \
|
|
33
|
+
"every offer they return still ends in hand-off, same as Amazon today. Neither " \
|
|
34
|
+
"key is echoed back; Enter keeps whatever's already set.")
|
|
35
|
+
assignments = collect
|
|
36
|
+
return @prompt.say("Left retailer offer source keys unchanged.") if assignments.empty?
|
|
37
|
+
|
|
38
|
+
path = DotEnv.update!(assignments)
|
|
39
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
40
|
+
end
|
|
41
|
+
|
|
42
|
+
private
|
|
43
|
+
|
|
44
|
+
def collect
|
|
45
|
+
FIELDS.each_with_object({}) do |(var, label), assignments|
|
|
46
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
47
|
+
answer = @prompt.ask_secret(label, hint: hint)
|
|
48
|
+
assignments[var] = answer if answer
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
55
|
+
end
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
require_relative "../../dot_env"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
class SetupWizard
|
|
6
|
+
module Steps
|
|
7
|
+
# Step 2 (docs/plans/buy-skill-and-local-browser.md Phase 4): Brave
|
|
8
|
+
# and Google CSE search keys, written to ~/.portage/.env. See
|
|
9
|
+
# Doctor#search_backend_finding — with neither set, `find`/`buy`
|
|
10
|
+
# with no URL only ever resolves DuckDuckGo's Instant Answer API,
|
|
11
|
+
# which is keyless but only ever matches a specific brand/product
|
|
12
|
+
# name, not an open-ended query ("coffee", "hiking boots").
|
|
13
|
+
class SearchKeys
|
|
14
|
+
FIELDS = {
|
|
15
|
+
"BRAVE_SEARCH_API_KEY" => "Brave Search API key",
|
|
16
|
+
"GOOGLE_CSE_KEY" => "Google Programmable Search API key",
|
|
17
|
+
"GOOGLE_CSE_CX" => "Google Programmable Search engine id (cx)"
|
|
18
|
+
}.freeze
|
|
19
|
+
|
|
20
|
+
# Google's `cx` names which engine to query, not a credential —
|
|
21
|
+
# only the two API keys are secrets that never echo back.
|
|
22
|
+
SECRET = %w[BRAVE_SEARCH_API_KEY GOOGLE_CSE_KEY].freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Search API keys"
|
|
27
|
+
def default_yes? = true
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("DuckDuckGo's free API is already active and needs no key, but only resolves a " \
|
|
31
|
+
"specific brand/product name — set either Brave's key, or both Google fields, for " \
|
|
32
|
+
"real open-ended search. Neither key is echoed back; Enter keeps whatever's " \
|
|
33
|
+
"already set.")
|
|
34
|
+
assignments = collect
|
|
35
|
+
return @prompt.say("Left search API keys unchanged.") if assignments.empty?
|
|
36
|
+
|
|
37
|
+
path = DotEnv.update!(assignments)
|
|
38
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
39
|
+
end
|
|
40
|
+
|
|
41
|
+
private
|
|
42
|
+
|
|
43
|
+
def collect
|
|
44
|
+
FIELDS.each_with_object({}) do |(var, label), assignments|
|
|
45
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
46
|
+
answer = SECRET.include?(var) ? @prompt.ask_secret(label, hint: hint) : @prompt.ask(label, hint: hint)
|
|
47
|
+
assignments[var] = answer if answer
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
52
|
+
end
|
|
53
|
+
end
|
|
54
|
+
end
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
require_relative "../../shipping_profile"
|
|
2
|
+
require_relative "../../dot_env"
|
|
3
|
+
|
|
4
|
+
module Portage
|
|
5
|
+
module Cli
|
|
6
|
+
class SetupWizard
|
|
7
|
+
module Steps
|
|
8
|
+
# Step 1 (docs/plans/buy-skill-and-local-browser.md Phase 4):
|
|
9
|
+
# PORTAGE_SHIP_*, written to ~/.portage/.env. Without at least the
|
|
10
|
+
# required fields, `portage buy` sends no shipping destination to
|
|
11
|
+
# your own store's checkout, and without the country a native UCP
|
|
12
|
+
# store prices in no market and can report in-stock items as out of
|
|
13
|
+
# stock (see Doctor#shipping_finding, which this step exists to
|
|
14
|
+
# resolve).
|
|
15
|
+
class Shipping
|
|
16
|
+
LABELS = {
|
|
17
|
+
street_address: "Street address", extended_address: "Apt/suite (optional)",
|
|
18
|
+
address_locality: "City", address_region: "State/region (optional)",
|
|
19
|
+
address_country: "Country (ISO 3166-1 alpha-2, e.g. US, GB)", postal_code: "Postal code",
|
|
20
|
+
first_name: "First name (optional)", last_name: "Last name (optional)",
|
|
21
|
+
phone_number: "Phone (optional)"
|
|
22
|
+
}.freeze
|
|
23
|
+
|
|
24
|
+
def initialize(prompt:) = @prompt = prompt
|
|
25
|
+
|
|
26
|
+
def title = "Shipping address"
|
|
27
|
+
def default_yes? = true
|
|
28
|
+
|
|
29
|
+
def call
|
|
30
|
+
@prompt.say("Enter keeps whatever's already set for a field.")
|
|
31
|
+
assignments = collect
|
|
32
|
+
return @prompt.say("Left shipping address unchanged.") if assignments.empty?
|
|
33
|
+
|
|
34
|
+
path = DotEnv.update!(assignments)
|
|
35
|
+
@prompt.say("Saved #{assignments.keys.join(', ')} to #{path} (chmod 600).")
|
|
36
|
+
end
|
|
37
|
+
|
|
38
|
+
private
|
|
39
|
+
|
|
40
|
+
def collect
|
|
41
|
+
ShippingProfile::ENV_VARS.each_with_object({}) do |(key, var), assignments|
|
|
42
|
+
hint = ENV.fetch(var, nil).to_s.empty? ? "not set" : "already set"
|
|
43
|
+
answer = @prompt.ask(LABELS.fetch(key), hint: hint)
|
|
44
|
+
assignments[var] = answer if answer
|
|
45
|
+
end
|
|
46
|
+
end
|
|
47
|
+
end
|
|
48
|
+
end
|
|
49
|
+
end
|
|
50
|
+
end
|
|
51
|
+
end
|
|
@@ -0,0 +1,74 @@
|
|
|
1
|
+
require_relative "setup_wizard/prompt"
|
|
2
|
+
require_relative "setup_wizard/steps/shipping"
|
|
3
|
+
require_relative "setup_wizard/steps/search_keys"
|
|
4
|
+
require_relative "setup_wizard/steps/retailer_keys"
|
|
5
|
+
require_relative "setup_wizard/steps/agent_profile"
|
|
6
|
+
require_relative "setup_wizard/steps/browser_import"
|
|
7
|
+
require_relative "setup_wizard/steps/index_build"
|
|
8
|
+
require_relative "setup_wizard/steps/policy"
|
|
9
|
+
require_relative "setup_wizard/steps/handoff"
|
|
10
|
+
|
|
11
|
+
module Portage
|
|
12
|
+
module Cli
|
|
13
|
+
# `portage setup`'s interactive wizard (docs/plans/
|
|
14
|
+
# buy-skill-and-local-browser.md Phase 4) — Cli.run_doctor/run_setup
|
|
15
|
+
# decide *whether* to run this (a real TTY, no --json, and either the
|
|
16
|
+
# command was `setup` outright or `doctor`/`configure` found nothing
|
|
17
|
+
# configured at all; see Doctor#nothing_configured?); this class is
|
|
18
|
+
# just the steps themselves, once that decision is already made
|
|
19
|
+
# (Phase 4 shipped seven; Phase 7 added RetailerKeys as an eighth, opt
|
|
20
|
+
# -in step between search keys and the agent profile).
|
|
21
|
+
#
|
|
22
|
+
# Every step: says what it's for, offers to skip (Enter keeps its own
|
|
23
|
+
# default — "on" for the foundational steps, "off" for the ones that
|
|
24
|
+
# are opt-in: browser import and index build's network/filesystem
|
|
25
|
+
# sweeps, and RetailerKeys' per-retailer credentials), and re-runs
|
|
26
|
+
# cleanly — Enter on any individual question inside a step
|
|
27
|
+
# always keeps whatever's already set, never clears it. No step raises
|
|
28
|
+
# its own error class: each one leans on the command it delegates to
|
|
29
|
+
# (`portage generate agent-profile`, `portage browser import`, `portage
|
|
30
|
+
# index build`, `portage policy set`) for its own validation and error
|
|
31
|
+
# reporting, so this class has nothing UCP- or network-specific to
|
|
32
|
+
# rescue.
|
|
33
|
+
class SetupWizard
|
|
34
|
+
INTRO = <<~TEXT.freeze
|
|
35
|
+
Portage setup
|
|
36
|
+
|
|
37
|
+
Walks through shipping, search, your agent profile, browser import,
|
|
38
|
+
the local store index, spending caps and hand-off. Every step can
|
|
39
|
+
be skipped, and this re-runs cleanly at any time — nothing you
|
|
40
|
+
already have set is cleared just by pressing Enter.
|
|
41
|
+
TEXT
|
|
42
|
+
|
|
43
|
+
STEPS = [Steps::Shipping, Steps::SearchKeys, Steps::RetailerKeys, Steps::AgentProfile, Steps::BrowserImport,
|
|
44
|
+
Steps::IndexBuild, Steps::Policy, Steps::Handoff].freeze
|
|
45
|
+
|
|
46
|
+
def initialize(input: $stdin, output: $stdout)
|
|
47
|
+
@prompt = Prompt.new(input: input, output: output)
|
|
48
|
+
end
|
|
49
|
+
|
|
50
|
+
# @return [Integer] always 0 — nothing about walking through (or
|
|
51
|
+
# skipping) setup steps is a failure exit code; a step that hits a
|
|
52
|
+
# real problem (a bad flag reaching `portage policy set`, a browser
|
|
53
|
+
# import permission error) reports it itself, same as running that
|
|
54
|
+
# command directly would.
|
|
55
|
+
def call
|
|
56
|
+
@prompt.say(INTRO)
|
|
57
|
+
STEPS.each { |step_class| run_step(step_class) }
|
|
58
|
+
@prompt.say("\nDone — run `portage doctor` any time to see the current state, or `portage setup` " \
|
|
59
|
+
"again to change anything above.")
|
|
60
|
+
0
|
|
61
|
+
end
|
|
62
|
+
|
|
63
|
+
private
|
|
64
|
+
|
|
65
|
+
def run_step(step_class)
|
|
66
|
+
step = step_class.new(prompt: @prompt)
|
|
67
|
+
@prompt.say("\n== #{step.title} ==")
|
|
68
|
+
return @prompt.say("Skipped.") unless @prompt.confirm("Configure this now?", default: step.default_yes?)
|
|
69
|
+
|
|
70
|
+
step.call
|
|
71
|
+
end
|
|
72
|
+
end
|
|
73
|
+
end
|
|
74
|
+
end
|
data/lib/portage/cli/version.rb
CHANGED
data/lib/portage/cli/webmcp.rb
CHANGED
|
@@ -8,18 +8,25 @@ module Portage
|
|
|
8
8
|
# caller who actually passes `Buy.new(webmcp_bridge:)` ever needs it
|
|
9
9
|
# installed.
|
|
10
10
|
module Webmcp
|
|
11
|
-
#
|
|
12
|
-
#
|
|
11
|
+
# WebMcp::Autofill, Presets, Matcher and Fingerprint, which the CLI
|
|
12
|
+
# calls, first shipped in portage-ucp-webmcp 0.2.0. An older install
|
|
13
|
+
# counts as not installed rather than failing with a NameError later.
|
|
14
|
+
MIN_VERSION = "0.2.0".freeze
|
|
15
|
+
|
|
16
|
+
# @return [Boolean] whether a new-enough portage-ucp-webmcp could be
|
|
17
|
+
# loaded. Memoized: `require` runs once per process.
|
|
13
18
|
def self.available?
|
|
14
19
|
return @available unless @available.nil?
|
|
15
20
|
|
|
16
21
|
@available = begin
|
|
17
22
|
require "portage/ucp/webmcp"
|
|
18
|
-
|
|
23
|
+
supported?(Portage::Ucp::WebMcp::VERSION)
|
|
19
24
|
rescue LoadError
|
|
20
25
|
false
|
|
21
26
|
end
|
|
22
27
|
end
|
|
28
|
+
|
|
29
|
+
def self.supported?(version) = Gem::Version.new(version) >= Gem::Version.new(MIN_VERSION)
|
|
23
30
|
end
|
|
24
31
|
end
|
|
25
32
|
end
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
module Portage
|
|
2
|
+
module Cli
|
|
3
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3, decision 2: even once
|
|
4
|
+
# `--autofill`/`PORTAGE_WEBMCP_AUTOFILL=approve` has opted a run in
|
|
5
|
+
# (WebmcpAutofillMode), nothing is typed into the store's checkout page
|
|
6
|
+
# until the shopper approves, in this prompt, exactly which fields and
|
|
7
|
+
# values are about to be entered — same shape as Phase 2's
|
|
8
|
+
# WebmcpMappingConfirm, applied to autofill instead of a tool mapping.
|
|
9
|
+
#
|
|
10
|
+
# Fields is always contact email + shipping address here — WebmcpFields
|
|
11
|
+
# never builds a payment one — but this class has no opinion on that;
|
|
12
|
+
# it only ever shows the shopper what it was given and asks.
|
|
13
|
+
class WebmcpAutofillConfirm
|
|
14
|
+
# @param interactive [Boolean] false under --json or with no TTY on
|
|
15
|
+
# stdin — same posture as WebmcpMappingConfirm: no interactive
|
|
16
|
+
# prompt possible means no autofill, not a guessed default.
|
|
17
|
+
def initialize(interactive:, input: $stdin, output: $stdout)
|
|
18
|
+
@interactive = interactive
|
|
19
|
+
@input = input
|
|
20
|
+
@output = output
|
|
21
|
+
end
|
|
22
|
+
|
|
23
|
+
# @param fields [Hash{String=>String}] autocomplete token => value.
|
|
24
|
+
# @return [Boolean] whether the shopper approved filling exactly these
|
|
25
|
+
# fields. false for an empty `fields` too — nothing to confirm.
|
|
26
|
+
def call(fields)
|
|
27
|
+
return false if !@interactive || fields.empty?
|
|
28
|
+
|
|
29
|
+
@output.puts "Autofill is ready to enter this on the store's own checkout page — contact and " \
|
|
30
|
+
"shipping only, never payment:"
|
|
31
|
+
fields.each { |token, value| @output.puts " #{token}: #{value}" }
|
|
32
|
+
@output.print "Fill these fields now? [y/N] "
|
|
33
|
+
@output.flush
|
|
34
|
+
@input.gets.to_s.strip.downcase == "y"
|
|
35
|
+
end
|
|
36
|
+
end
|
|
37
|
+
end
|
|
38
|
+
end
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
require_relative "shipping_profile"
|
|
2
|
+
|
|
3
|
+
module Portage
|
|
4
|
+
module Cli
|
|
5
|
+
# docs/plans/webmcp-universal-outbound.md Phase 3 — the shopper-approved
|
|
6
|
+
# data autofill is ever allowed to type into a store's checkout page:
|
|
7
|
+
# contact email and the PORTAGE_SHIP_* shipping address
|
|
8
|
+
# (Portage::Cli::ShippingProfile), each named by the WHATWG
|
|
9
|
+
# autocomplete token (https://html.spec.whatwg.org/#autofill) a standards
|
|
10
|
+
# -following checkout page's own field should carry, which is what
|
|
11
|
+
# Portage::Ucp::WebMcp::Autofill/assets/autofill.js match against.
|
|
12
|
+
#
|
|
13
|
+
# Deliberately the only place in this gem that reads PORTAGE_SHIP_EMAIL
|
|
14
|
+
# — a contact email is a WebMCP-autofill-only concern (native/adapter
|
|
15
|
+
# checkouts take it as part of `context`/the adapter's own contract, not
|
|
16
|
+
# a standalone shipping field), so it doesn't belong on ShippingProfile
|
|
17
|
+
# or BuyerContext, both of which are shared with paths that never touch
|
|
18
|
+
# a browser.
|
|
19
|
+
module WebmcpAutofillFields
|
|
20
|
+
EMAIL_ENV_VAR = "PORTAGE_SHIP_EMAIL".freeze
|
|
21
|
+
|
|
22
|
+
# ShippingProfile attribute => the autocomplete token
|
|
23
|
+
# assets/autofill.js looks for on the checkout page.
|
|
24
|
+
TOKENS = {
|
|
25
|
+
first_name: "shipping given-name",
|
|
26
|
+
last_name: "shipping family-name",
|
|
27
|
+
street_address: "shipping address-line1",
|
|
28
|
+
extended_address: "shipping address-line2",
|
|
29
|
+
address_locality: "shipping address-level2",
|
|
30
|
+
address_region: "shipping address-level1",
|
|
31
|
+
postal_code: "shipping postal-code",
|
|
32
|
+
address_country: "shipping country",
|
|
33
|
+
phone_number: "shipping tel"
|
|
34
|
+
}.freeze
|
|
35
|
+
|
|
36
|
+
module_function
|
|
37
|
+
|
|
38
|
+
# @param address [Portage::Ucp::PostalAddress, nil] nil (the default)
|
|
39
|
+
# reads PORTAGE_SHIP_* fresh; injectable so a spec doesn't need real
|
|
40
|
+
# env vars set.
|
|
41
|
+
# @return [Hash{String=>String}] autocomplete token => value. Empty
|
|
42
|
+
# when neither an email nor a usable shipping address is
|
|
43
|
+
# configured — #call in Buy treats that as nothing to autofill,
|
|
44
|
+
# same as no PORTAGE_SHIP_* today.
|
|
45
|
+
def build(address: Portage::Cli::ShippingProfile.from_env)
|
|
46
|
+
fields = {}
|
|
47
|
+
email = ENV.fetch(EMAIL_ENV_VAR, nil)
|
|
48
|
+
fields["email"] = email unless email.to_s.strip.empty?
|
|
49
|
+
|
|
50
|
+
return fields unless address
|
|
51
|
+
|
|
52
|
+
TOKENS.each do |attribute, token|
|
|
53
|
+
value = address.public_send(attribute)
|
|
54
|
+
fields[token] = value unless value.to_s.strip.empty?
|
|
55
|
+
end
|
|
56
|
+
fields
|
|
57
|
+
end
|
|
58
|
+
end
|
|
59
|
+
end
|
|
60
|
+
end
|