portage-cli

Ships the portage executable — one CLI command to buy from any store, native UCP or not.

portage buy https://your-shop.example --query "snowboard" --qty 1 --payment-token spt_1a2b3c...

portage buy <url>:

  1. Tries native UCP discovery first (GET /.well-known/ucp, then a <link rel="ucp">-style tag on the homepage) — zero credentials, works on any store that's opted in.
  2. Falls back to a portage-ucp-<platform> adapter only when this process already has that platform's own credentials in env — i.e. it's your own store, or one you're integrated with.
  3. Otherwise says so plainly and stops — never scrapes or session-hijacks as an anonymous shopper. That fallback path is a ToS violation this gem deliberately refuses to take.

Don't have a URL? portage find asks a search backend which stores might sell the thing, keeps the ones that answer /.well-known/ucp, and lists what they actually stock:

portage find --query "burton snowboard" --max-price 400

portage buy with no URL runs that search and then buys the offer you pick.

Depends on portage-ucp (for platform detection via Resolver) and portage-ucp-client (for the actual buy calls). No single adapter gem is a hard dependency — install whichever portage-ucp-<platform> gem matches the store you're integrated with, if any.

Installation

# Gemfile
gem "portage-cli"
bundle install

Or standalone:

gem install portage-cli

Usage

portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
                                [--yes] [--dry-run] [--json]
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
portage find --query "..." [--max-price N] [--limit N] [--json]
  • --query — search term. Against the store's catalog when you name a store, against the search backends when you don't.
  • --qty — quantity, default 1.
  • --payment-token — a tokenized payment credential (never a raw card number — PaymentTokenGuard in the core gem rejects those before they reach the wire). Omit for --dry-run or to just browse.
  • --product-id — buy exactly this product rather than whatever the catalog search ranks first. If the id isn't in the results, nothing is bought.
  • --store — name the merchant without giving a full URL; skips the search.
  • --max-price — in major units (400 means 400), compared per offer in that offer's own currency. No FX conversion.
  • --limit — how many candidate stores to probe, capped at 12.
  • --yes — skip the confirmation prompt before completing checkout.
  • --dry-run — resolve and price the order without completing checkout.
  • --json — machine-readable report instead of the human-readable summary.

Exits 0 when a checkout completed (or a dry-run/browse/search resolved successfully), 1 otherwise — including the "no native manifest, no adapter credentials" dead-end case, so it's scriptable in CI.

Buying without a URL

portage find and URL-less portage buy share one pipeline:

  1. Ask the backends which stores might sell it (see below).
  2. Probe each candidate origin for /.well-known/ucp, one request each, throttled, with results cached in ~/.portage/discovery-cache.json (misses for a day, hits for six hours) so repeat searches don't re-probe the same hosts. A tool that fans out an unsolicited request per host per invocation is a crawler; this one isn't.
  3. Search the survivors' catalogs and merge the offers, buyable stores first, then cheapest.

--yes is not enough to buy from a search result. With a URL you chose the merchant; without one a search ranker chose it, so the merchant has to be named by a person — either --store, or an interactive pick from the listed offers. A piped or CI run with no --store prints the offers and stops.

Search backends

Every backend talks to a documented API. None of them parse a results page: scraping a search engine is the same class of ToS violation portage buy already refuses to commit against a merchant.

Backend Credentials Notes
Allowlist ~/.portage/stores.yml (YAML array of URLs) or PORTAGE_STORES (comma-separated) Stores you already trust. Checked first, costs no network call.
DuckDuckGo none The Instant Answer API. Answers entity queries, not web queries: burton snowboards resolves to burton.com, snowboard resolves to nothing.
Brave BRAVE_SEARCH_API_KEY Real web results. Set this up if you want open-ended queries to work.
Google GOOGLE_CSE_KEY + GOOGLE_CSE_CX Programmable Search JSON API.

Backends that have no credentials sit out; DuckDuckGo is the keyless default because it's the only no-key engine with a real API, and its narrowness is the price of not scraping.

Development

bundle exec rspec
bundle exec rubocop

License

MIT — Copyright (c) 2026 Tom Whitbread.