portage-cli 0.7.5 → 0.9.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (82) hide show
  1. checksums.yaml +4 -4
  2. data/CHANGELOG.md +616 -0
  3. data/README.md +302 -5
  4. data/known-stores/categories.yml +1263 -0
  5. data/lib/portage/cli/agent_profile_url.rb +30 -0
  6. data/lib/portage/cli/approval_policy.rb +56 -0
  7. data/lib/portage/cli/approve.rb +135 -0
  8. data/lib/portage/cli/browser_import/categorize.rb +59 -0
  9. data/lib/portage/cli/browser_import/confirm.rb +35 -0
  10. data/lib/portage/cli/browser_import/domains.rb +47 -0
  11. data/lib/portage/cli/browser_import/filter.rb +91 -0
  12. data/lib/portage/cli/browser_import/importer.rb +248 -0
  13. data/lib/portage/cli/browser_import/plist_xml.rb +72 -0
  14. data/lib/portage/cli/browser_import/prober.rb +60 -0
  15. data/lib/portage/cli/browser_import/profiles.rb +114 -0
  16. data/lib/portage/cli/browser_import/readers.rb +179 -0
  17. data/lib/portage/cli/browser_import/saver.rb +62 -0
  18. data/lib/portage/cli/browser_import/sqlite.rb +68 -0
  19. data/lib/portage/cli/browser_import.rb +23 -0
  20. data/lib/portage/cli/browser_opener.rb +38 -0
  21. data/lib/portage/cli/browser_profile/allowlist.rb +40 -0
  22. data/lib/portage/cli/browser_profile/bridge.rb +120 -0
  23. data/lib/portage/cli/browser_profile/browsers.rb +69 -0
  24. data/lib/portage/cli/browser_profile/cdp.rb +67 -0
  25. data/lib/portage/cli/browser_profile/cdp_socket.rb +186 -0
  26. data/lib/portage/cli/browser_profile/errors.rb +26 -0
  27. data/lib/portage/cli/browser_profile/launcher.rb +34 -0
  28. data/lib/portage/cli/browser_profile/profile.rb +93 -0
  29. data/lib/portage/cli/browser_profile.rb +25 -0
  30. data/lib/portage/cli/buy.rb +559 -29
  31. data/lib/portage/cli/checkout_handoff.rb +5 -24
  32. data/lib/portage/cli/classifier.rb +158 -0
  33. data/lib/portage/cli/compare.rb +3 -0
  34. data/lib/portage/cli/doctor.rb +155 -1
  35. data/lib/portage/cli/dot_env.rb +55 -0
  36. data/lib/portage/cli/find.rb +103 -12
  37. data/lib/portage/cli/handoff_agents.rb +186 -0
  38. data/lib/portage/cli/handoff_only.rb +94 -0
  39. data/lib/portage/cli/handoff_reconciler.rb +15 -1
  40. data/lib/portage/cli/handoff_target.rb +61 -0
  41. data/lib/portage/cli/history.rb +47 -2
  42. data/lib/portage/cli/human_prompt.rb +118 -0
  43. data/lib/portage/cli/index/builder.rb +335 -0
  44. data/lib/portage/cli/index/exporter.rb +91 -0
  45. data/lib/portage/cli/index/known_cache.rb +155 -0
  46. data/lib/portage/cli/index/product_store.rb +101 -0
  47. data/lib/portage/cli/index/sources/browser.rb +31 -0
  48. data/lib/portage/cli/index/sources/shopify_catalog.rb +82 -0
  49. data/lib/portage/cli/index/sources/stores_file.rb +58 -0
  50. data/lib/portage/cli/index/sources/webmcp_sweep.rb +29 -0
  51. data/lib/portage/cli/index/sources/wikidata.rb +95 -0
  52. data/lib/portage/cli/index/sources.rb +44 -0
  53. data/lib/portage/cli/index/store.rb +109 -0
  54. data/lib/portage/cli/index.rb +20 -0
  55. data/lib/portage/cli/known_stores_url.rb +15 -0
  56. data/lib/portage/cli/money.rb +18 -0
  57. data/lib/portage/cli/offer_choice.rb +38 -0
  58. data/lib/portage/cli/offer_sources.rb +460 -0
  59. data/lib/portage/cli/payment_methods.rb +24 -3
  60. data/lib/portage/cli/pick.rb +167 -0
  61. data/lib/portage/cli/product_page.rb +84 -0
  62. data/lib/portage/cli/quotes.rb +82 -0
  63. data/lib/portage/cli/search_backends.rb +337 -12
  64. data/lib/portage/cli/setup_wizard/prompt.rb +67 -0
  65. data/lib/portage/cli/setup_wizard/steps/agent_profile.rb +60 -0
  66. data/lib/portage/cli/setup_wizard/steps/browser_import.rb +29 -0
  67. data/lib/portage/cli/setup_wizard/steps/handoff.rb +100 -0
  68. data/lib/portage/cli/setup_wizard/steps/index_build.rb +31 -0
  69. data/lib/portage/cli/setup_wizard/steps/policy.rb +60 -0
  70. data/lib/portage/cli/setup_wizard/steps/retailer_keys.rb +55 -0
  71. data/lib/portage/cli/setup_wizard/steps/search_keys.rb +54 -0
  72. data/lib/portage/cli/setup_wizard/steps/shipping.rb +51 -0
  73. data/lib/portage/cli/setup_wizard.rb +74 -0
  74. data/lib/portage/cli/version.rb +1 -1
  75. data/lib/portage/cli/webmcp.rb +10 -3
  76. data/lib/portage/cli/webmcp_autofill_confirm.rb +38 -0
  77. data/lib/portage/cli/webmcp_autofill_fields.rb +60 -0
  78. data/lib/portage/cli/webmcp_autofill_mode.rb +39 -0
  79. data/lib/portage/cli/webmcp_mapping_confirm.rb +68 -0
  80. data/lib/portage/cli/webmcp_mappings.rb +84 -0
  81. data/lib/portage/cli.rb +930 -43
  82. metadata +67 -2
@@ -0,0 +1,31 @@
1
+ module Portage
2
+ module Cli
3
+ module Index
4
+ module Sources
5
+ # A pointer, not a reader. Browser-derived entries come from
6
+ # `portage browser import` (BrowserImport::Importer, Phase 3), which
7
+ # writes them straight into Index::Store — `sources: ["history"]`/
8
+ # `["bookmark"]` — only after the user has seen the list and
9
+ # approved it. `index build` must never read a browser's history on
10
+ # its own (that would skip the approval), so this source yields
11
+ # nothing and isn't in Sources::DEFAULT_NAMES; it stays in the
12
+ # registry so `portage index sources` still says where browser
13
+ # entries come from.
14
+ class Browser
15
+ def name = "browser"
16
+
17
+ def description
18
+ "Bookmarks/history shop domains — written by `portage browser import` after you approve the list " \
19
+ "(never read by `index build` itself). Yields nothing here."
20
+ end
21
+
22
+ def source_path = nil
23
+
24
+ # `**` accepts (and ignores) the shared Source#candidates(queries:)
25
+ # interface.
26
+ def candidates(**) = []
27
+ end
28
+ end
29
+ end
30
+ end
31
+ end
@@ -0,0 +1,82 @@
1
+ require "yaml"
2
+
3
+ require_relative "../../offer_sources"
4
+ require_relative "../../buyer_context"
5
+ require_relative "../../classifier"
6
+
7
+ module Portage
8
+ module Cli
9
+ module Index
10
+ module Sources
11
+ # Harvests merchant origins and product identities from Shopify's
12
+ # global catalog — the same source `find` already uses live
13
+ # (OfferSources::ShopifyCatalog), reused here rather than
14
+ # duplicating its catalog-search/variant-URL logic (docs/plans/
15
+ # buy-skill-and-local-browser.md Phase 2b: "reuse the Phase 1
16
+ # ShopifyCatalog code rather than duplicating it").
17
+ #
18
+ # One query per top-level taxonomy node by default (21 nodes —
19
+ # known-stores/categories.yml's own top level, its keyword rather
20
+ # than its full "Parent > Child" name, so each query is a plain
21
+ # shopping term like "electronics" or "apparel" instead of a taxon
22
+ # label). Not per-country: BuyerContext.from_env already applies
23
+ # whatever PORTAGE_SHIP_COUNTRY/PORTAGE_CURRENCY the user has set,
24
+ # same as every other catalog call this CLI makes, and fanning the
25
+ # same 21 queries out across every market on every `index build`
26
+ # would turn a several-second command into a slow one for no
27
+ # benefit a single run doesn't already get from the user's own
28
+ # locale (Open question 1, resolved 2026-09-28).
29
+ class ShopifyCatalog
30
+ PER_QUERY_LIMIT = 20
31
+
32
+ def initialize(catalog: OfferSources::ShopifyCatalog.new, categories_path: Classifier::KNOWN_PATH)
33
+ @catalog = catalog
34
+ @categories_path = categories_path
35
+ end
36
+
37
+ def name = "shopify_catalog"
38
+
39
+ def description
40
+ "Shopify's global catalog search (catalog.shopify.com) — one query per top-level taxonomy " \
41
+ "node, or --queries FILE. Harvests merchant origins from variants[].url and product " \
42
+ "identities from the results."
43
+ end
44
+
45
+ def source_path = nil
46
+
47
+ # @param queries [Array<String>, nil] nil (the default) runs one
48
+ # query per top-level taxonomy node.
49
+ # @return [Array<Hash>] sightings: origin:, url:, title:, brand:,
50
+ # gtin:.
51
+ def candidates(queries: nil)
52
+ (queries || default_queries).flat_map { |query| sightings_for(query) }
53
+ end
54
+
55
+ private
56
+
57
+ def sightings_for(query)
58
+ @catalog.offers(query, limit: PER_QUERY_LIMIT, context: BuyerContext.from_env).map do |offer|
59
+ { origin: offer[:store], url: offer[:url], title: offer[:title], brand: nil, gtin: nil }
60
+ end
61
+ rescue StandardError
62
+ []
63
+ end
64
+
65
+ # The shipped taxonomy's own top-level keyword ("animals",
66
+ # "apparel", "electronics", ...) rather than its full name — a
67
+ # single plain word is a better catalog search term than
68
+ # "Apparel & Accessories" would be.
69
+ def default_queries
70
+ data = YAML.safe_load_file(@categories_path)
71
+ return [] unless data.is_a?(Hash)
72
+
73
+ top_level = data.values.reject { |node| node["name"].to_s.include?(">") }
74
+ top_level.filter_map { |node| Array(node["keywords"]).first }.uniq
75
+ rescue StandardError
76
+ []
77
+ end
78
+ end
79
+ end
80
+ end
81
+ end
82
+ end
@@ -0,0 +1,58 @@
1
+ require "uri"
2
+
3
+ require_relative "../../search_backends"
4
+
5
+ module Portage
6
+ module Cli
7
+ module Index
8
+ module Sources
9
+ # The user's own `~/.portage/stores.yml` (or `PORTAGE_STORES`) —
10
+ # already-trusted stores they listed themselves. Reuses
11
+ # SearchBackends::Allowlist's own parsing (env + file, bare URL or
12
+ # `{url:, categories:}`) rather than re-reading the file, so a
13
+ # change to that format only has to be made in one place.
14
+ class StoresFile
15
+ def initialize(allowlist: SearchBackends::Allowlist.new)
16
+ @allowlist = allowlist
17
+ end
18
+
19
+ def name = "stores_file"
20
+
21
+ def description
22
+ "Your own ~/.portage/stores.yml / PORTAGE_STORES — stores you've already trusted directly."
23
+ end
24
+
25
+ def source_path = SearchBackends::Allowlist::PATH
26
+
27
+ # @return [Array<Hash>] one sighting per store, no product
28
+ # identity (stores.yml only ever names a store, never a
29
+ # product). `**` accepts (and ignores) the shared
30
+ # Source#candidates(queries:) interface — stores.yml has no
31
+ # query of its own to run.
32
+ def candidates(**)
33
+ @allowlist.stores.filter_map { |entry| sighting_for(entry[:url]) }
34
+ end
35
+
36
+ private
37
+
38
+ def sighting_for(url)
39
+ origin = origin_of(url)
40
+ return nil unless origin
41
+
42
+ { origin: origin, url: nil, title: nil, brand: nil, gtin: nil }
43
+ end
44
+
45
+ def origin_of(url)
46
+ uri = URI.parse(url.to_s)
47
+ return nil unless uri.host && uri.scheme.to_s.start_with?("http")
48
+
49
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
50
+ "#{uri.scheme}://#{uri.host}#{port}"
51
+ rescue URI::InvalidURIError
52
+ nil
53
+ end
54
+ end
55
+ end
56
+ end
57
+ end
58
+ end
@@ -0,0 +1,29 @@
1
+ module Portage
2
+ module Cli
3
+ module Index
4
+ module Sources
5
+ # Would detect each known origin's WebMCP preset by driving a
6
+ # browser bridge against it (docs/plans/webmcp-universal-outbound.md)
7
+ # — that bridge doesn't exist yet (it's Phase 6 here / WebMCP Phase
8
+ # 3-4), so this source skips cleanly rather than failing: it's
9
+ # listed by `portage index sources` so its eventual arrival is
10
+ # discoverable, but `candidates` is always empty until a bridge is
11
+ # wired in.
12
+ class WebmcpSweep
13
+ def name = "webmcp_sweep"
14
+
15
+ def description
16
+ "Detects a WebMCP preset per known origin via the browser bridge. No bridge is wired up yet " \
17
+ "— skips cleanly, yields nothing."
18
+ end
19
+
20
+ def source_path = nil
21
+
22
+ # `**` accepts (and ignores) the shared Source#candidates(queries:)
23
+ # interface, since this source never runs a query of its own.
24
+ def candidates(**) = []
25
+ end
26
+ end
27
+ end
28
+ end
29
+ end
@@ -0,0 +1,95 @@
1
+ require "net/http"
2
+ require "uri"
3
+ require "json"
4
+ require "timeout"
5
+ require "portage/ucp/support/connection"
6
+
7
+ require_relative "../../user_agent"
8
+
9
+ module Portage
10
+ module Cli
11
+ module Index
12
+ module Sources
13
+ # Wikidata SPARQL for retail chains' and brands' official websites
14
+ # (CC0). Optional and **off by default** (Sources.default excludes
15
+ # it) — a live, read-only trial run of this exact query on
16
+ # 2026-09-28 returned real hits (Venchi, DNS, Fix Price, Avtomir and
17
+ # more, each with a genuine official-site URL), which is the bar
18
+ # the plan set for keeping it, but the yield skews toward chains
19
+ # with no UCP/WebMCP support at all — most of what it finds fails
20
+ # the `/.well-known/ucp` probe like any other candidate, same as a
21
+ # web-search backend's own noise. Worth keeping for the rare real
22
+ # hit, not worth running on every default `index build`.
23
+ class Wikidata
24
+ ENDPOINT = "https://query.wikidata.org/sparql".freeze
25
+ TIMEOUT = 15
26
+ LIMIT = 200
27
+
28
+ # wd:Q507619 = "retail chain". wdt:P279* walks subclasses, so a
29
+ # supermarket chain or department-store chain tagged as a
30
+ # subclass of retail chain is included too, not just an exact
31
+ # match.
32
+ QUERY = <<~SPARQL
33
+ SELECT ?item ?itemLabel ?website WHERE {
34
+ ?item wdt:P31/wdt:P279* wd:Q507619 .
35
+ ?item wdt:P856 ?website .
36
+ SERVICE wikibase:label { bd:serviceParam wikibase:language "en". }
37
+ } LIMIT #{LIMIT}
38
+ SPARQL
39
+ .freeze
40
+
41
+ def name = "wikidata"
42
+
43
+ def description
44
+ "Wikidata SPARQL for retail chains' official websites (CC0). Off by default (low yield) " \
45
+ "— opt in with --sources wikidata."
46
+ end
47
+
48
+ def source_path = nil
49
+
50
+ # `**` accepts (and ignores) the shared Source#candidates(queries:)
51
+ # interface — this source runs one fixed SPARQL query, never the
52
+ # caller's.
53
+ def candidates(**)
54
+ rows = Timeout.timeout(TIMEOUT) { fetch }
55
+ rows.filter_map { |row| sighting_for(row) }
56
+ rescue StandardError
57
+ []
58
+ end
59
+
60
+ private
61
+
62
+ def fetch
63
+ uri = URI.parse(ENDPOINT)
64
+ uri.query = URI.encode_www_form(query: QUERY)
65
+ headers = UserAgent.headers.merge("Accept" => "application/sparql-results+json")
66
+ response = Portage::Ucp::Support::Connection.start(
67
+ uri, route: :search, open_timeout: 5, read_timeout: TIMEOUT
68
+ ) { |http| http.get(uri.request_uri, headers) }
69
+ return [] unless response.is_a?(Net::HTTPSuccess)
70
+
71
+ Array(JSON.parse(response.body).dig("results", "bindings"))
72
+ end
73
+
74
+ def sighting_for(row)
75
+ url = row.dig("website", "value")
76
+ origin = origin_of(url)
77
+ return nil unless origin
78
+
79
+ { origin: origin, url: url, title: nil, brand: row.dig("itemLabel", "value"), gtin: nil }
80
+ end
81
+
82
+ def origin_of(url)
83
+ uri = URI.parse(url.to_s)
84
+ return nil unless uri.host && uri.scheme.to_s.start_with?("http")
85
+
86
+ port = uri.port == uri.default_port ? "" : ":#{uri.port}"
87
+ "#{uri.scheme}://#{uri.host}#{port}"
88
+ rescue URI::InvalidURIError
89
+ nil
90
+ end
91
+ end
92
+ end
93
+ end
94
+ end
95
+ end
@@ -0,0 +1,44 @@
1
+ require_relative "sources/shopify_catalog"
2
+ require_relative "sources/stores_file"
3
+ require_relative "sources/browser"
4
+ require_relative "sources/wikidata"
5
+ require_relative "sources/webmcp_sweep"
6
+
7
+ module Portage
8
+ module Cli
9
+ module Index
10
+ # One small file per source under index/sources/ — `portage index
11
+ # sources` lists exactly this registry, so what the command shows and
12
+ # what `index build` can actually run never drift apart.
13
+ module Sources
14
+ ALL = {
15
+ "shopify_catalog" => -> { ShopifyCatalog.new },
16
+ "stores_file" => -> { StoresFile.new },
17
+ "browser" => -> { Browser.new },
18
+ "wikidata" => -> { Wikidata.new },
19
+ "webmcp_sweep" => -> { WebmcpSweep.new }
20
+ }.freeze
21
+
22
+ # Runs with no extra opt-in: no bridge required, and no low-yield
23
+ # trade-off to accept up front. `wikidata` and `webmcp_sweep` still
24
+ # show up in `portage index sources`, just not run unless named in
25
+ # `--sources`. `browser` is listed but never a default — it yields
26
+ # nothing; `portage browser import` writes those entries itself
27
+ # (see Sources::Browser).
28
+ DEFAULT_NAMES = %w[shopify_catalog stores_file].freeze
29
+
30
+ def self.all = ALL.values.map(&:call)
31
+
32
+ def self.default = DEFAULT_NAMES.map { |n| build(n) }
33
+
34
+ # @param names [Array<String>]
35
+ # @return [Array<#name, #description, #source_path, #candidates>]
36
+ # unknown names are silently dropped — callers report them
37
+ # themselves (see Cli's own option-parsing errors elsewhere).
38
+ def self.by_name(names) = Array(names).filter_map { |n| build(n) }
39
+
40
+ def self.build(name) = ALL[name]&.call
41
+ end
42
+ end
43
+ end
44
+ end
@@ -0,0 +1,109 @@
1
+ require "json"
2
+ require "fileutils"
3
+ require "uri"
4
+
5
+ module Portage
6
+ module Cli
7
+ module Index
8
+ # `~/.portage/index/stores.json` — one entry per merchant origin
9
+ # `portage index build`/`refresh`/`add` has found and verified.
10
+ #
11
+ # This is untrusted, user-owned data, same posture as `stores.yml`
12
+ # before it's tagged: never checked into git (docs/plans/
13
+ # buy-skill-and-local-browser.md Phase 2b), never written into
14
+ # Portage::Ucp::Policy#merchant_allowlist, and never itself the
15
+ # "picked a store" a `--yes` buy requires — SearchBackends::Index just
16
+ # hands back candidate URLs, same as any other search backend, so
17
+ # `Cli.buy_from_search`'s existing --store/interactive-pick gate
18
+ # applies to an index-sourced offer exactly as it does to a web
19
+ # search result (see search_backends_spec.rb/cli_spec.rb for the
20
+ # specs proving both).
21
+ #
22
+ # Entry shape (string keys, JSON on disk): origin, platform,
23
+ # ucp_version, capabilities (array of "catalog"/"cart"/"checkout"),
24
+ # webmcp_preset, categories (category id => weight, top 5),
25
+ # sources (array of source names that found it), last_verified
26
+ # (unix seconds), handoff_only.
27
+ class Store
28
+ PATH = File.join(Dir.home, ".portage", "index", "stores.json").freeze
29
+
30
+ def initialize(path: PATH)
31
+ @path = path
32
+ end
33
+
34
+ def all = entries.values
35
+
36
+ def find(origin) = entries[origin]
37
+
38
+ # Merges `fields` onto whatever's already there for `origin` (or
39
+ # starts a fresh entry) — so a second source finding the same store
40
+ # adds to its `sources`/`categories` rather than clobbering the
41
+ # first source's findings.
42
+ def upsert(origin, **fields)
43
+ existing = entries[origin] || { "origin" => origin }
44
+ entries[origin] = existing.merge(fields.transform_keys(&:to_s))
45
+ write
46
+ entries[origin]
47
+ end
48
+
49
+ # @return [Integer] how many entries (0 or 1 — origins are unique)
50
+ # this host's removal actually dropped.
51
+ def remove(host)
52
+ before = entries.length
53
+ entries.reject! { |origin, _| host_of(origin) == host }
54
+ write
55
+ before - entries.length
56
+ end
57
+
58
+ # @return [Integer, nil] seconds since the oldest entry was
59
+ # verified — nil when the index is empty. `doctor` and `index
60
+ # show` use this to say how stale the local index is.
61
+ def oldest_verified_age(now: Time.now)
62
+ timestamps = entries.values.filter_map { |e| e["last_verified"] }
63
+ return nil if timestamps.empty?
64
+
65
+ now.to_i - timestamps.min
66
+ end
67
+
68
+ def exists? = File.exist?(@path)
69
+
70
+ private
71
+
72
+ def host_of(origin)
73
+ URI.parse(origin).host.to_s
74
+ rescue URI::InvalidURIError
75
+ ""
76
+ end
77
+
78
+ def entries
79
+ @entries ||= read
80
+ end
81
+
82
+ def read
83
+ return {} unless File.readable?(@path)
84
+
85
+ # A business name/title can carry non-ASCII bytes (curly quotes,
86
+ # ®, accents); reading with the process's default external
87
+ # encoding (US-ASCII on a bare-minimal LANG, confirmed live in a
88
+ # sandbox with no locale set) would otherwise raise on the very
89
+ # first non-ASCII byte and get silently swallowed below, quietly
90
+ # dropping the whole file's worth of entries — this was caught by
91
+ # this phase's own live index-build check.
92
+ parsed = JSON.parse(File.read(@path, encoding: "UTF-8"))
93
+ parsed.is_a?(Hash) ? parsed : {}
94
+ rescue StandardError
95
+ {}
96
+ end
97
+
98
+ # An index that can't be written just means this run's findings
99
+ # aren't saved — never a failed build.
100
+ def write
101
+ FileUtils.mkdir_p(File.dirname(@path))
102
+ File.write(@path, JSON.generate(@entries))
103
+ rescue StandardError
104
+ nil
105
+ end
106
+ end
107
+ end
108
+ end
109
+ end
@@ -0,0 +1,20 @@
1
+ require_relative "index/store"
2
+ require_relative "index/product_store"
3
+ require_relative "index/known_cache"
4
+ require_relative "index/sources"
5
+ require_relative "index/builder"
6
+
7
+ module Portage
8
+ module Cli
9
+ # `portage index` — a local store and product index the user builds
10
+ # themselves (docs/plans/buy-skill-and-local-browser.md Phase 2b),
11
+ # feeding `find` a fresh install's stores.yml can't. Never checked into
12
+ # git; never trusted the way stores.yml is (see Index::Store's own
13
+ # comment). Phase 2c adds the repo's own jsdelivr-hosted known-stores
14
+ # list (Index::KnownCache), fetched under the user's own entries here —
15
+ # never in place of them.
16
+ module Index
17
+ DIR = File.join(Dir.home, ".portage", "index").freeze
18
+ end
19
+ end
20
+ end
@@ -0,0 +1,15 @@
1
+ module Portage
2
+ module Cli
3
+ # Where the repo's own `known-stores/{stores,products}.json` (docs/plans/
4
+ # buy-skill-and-local-browser.md Phase 2c) are published for a fresh
5
+ # install to fetch: the same jsdelivr `@main` channel AgentProfileUrl
6
+ # already uses for the agent profile. Free GitHub, no Actions — a new
7
+ # entry reaches every install on its next refresh as soon as a PR
8
+ # merges, with no gem/brew release to wait on.
9
+ module KnownStoresUrl
10
+ BASE = "https://cdn.jsdelivr.net/gh/tomtom87/Portage@main/portage-cli/known-stores".freeze
11
+ STORES = "#{BASE}/stores.json".freeze
12
+ PRODUCTS = "#{BASE}/products.json".freeze
13
+ end
14
+ end
15
+ end
@@ -0,0 +1,18 @@
1
+ module Portage
2
+ module Cli
3
+ # How portage-cli prints an amount in minor units, in one place, so
4
+ # `find`/`history`/`buy` output, Buy's `quote_changed` message and the
5
+ # `approve` summary (docs/plans/human-pick-and-approve.md Phase 2) never
6
+ # disagree on what a total looks like. No FX and no zero-decimal
7
+ # currencies, same as Cli.to_minor_units.
8
+ module Money
9
+ module_function
10
+
11
+ # @param amount [Integer] minor units.
12
+ # @param currency [String, nil]
13
+ def format_amount(amount, currency)
14
+ "#{format('%.2f', amount / 100.0)}#{" #{currency}" if currency}"
15
+ end
16
+ end
17
+ end
18
+ end
@@ -0,0 +1,38 @@
1
+ require_relative "product_page"
2
+ require_relative "money"
3
+
4
+ module Portage
5
+ module Cli
6
+ # One offer as a render-ready choice (docs/plans/human-pick-and-approve.md
7
+ # Phase 2): what `portage pick` returns in `needs_pick`'s `choices[]`,
8
+ # and what HumanPrompt#choose lists for `pick` and `buy --query` alike.
9
+ # `url` is the product page as find/compare returned it; the agent shows
10
+ # it as a link next to the choice.
11
+ module OfferChoice
12
+ module_function
13
+
14
+ # @param offer [Hash] a find report's symbol-keyed offer or a saved
15
+ # (History) string-keyed one.
16
+ def for(offer)
17
+ offer = offer.transform_keys(&:to_s)
18
+ { ref: offer["offer_ref"], label: label(offer), store: offer["store"], product_id: offer["product_id"],
19
+ title: offer["title"], amount: offer["amount"], currency: offer["currency"],
20
+ checkout: offer["checkout"], url: offer["url"] }
21
+ end
22
+
23
+ def label(offer)
24
+ price = offer["amount"] ? Money.format_amount(offer["amount"], offer["currency"]) : "price n/a"
25
+ parts = [offer["store"], offer["title"], price]
26
+ parts << "browse only" if offer["checkout"] == false
27
+ parts.compact.join(" — ")
28
+ end
29
+
30
+ # The tty `v N` answer for one choice: the page's own outcome message.
31
+ def view_message(choice)
32
+ return "Nothing to view for that choice." unless choice[:store]
33
+
34
+ ProductPage.new(url: choice[:url], store: choice[:store]).open[:message]
35
+ end
36
+ end
37
+ end
38
+ end