Skip to content

Latest commit

 

History

History
438 lines (346 loc) · 17.8 KB

File metadata and controls

438 lines (346 loc) · 17.8 KB

CLI usage tutorial: install, search, dry-run buy

A walkthrough of portage-cli from a clean machine — install, search-only find, dry-run buy, and what to do when the free search backend comes back empty.

Install

With Homebrew (macOS or Linux), which installs the CLI and every adapter gem on Homebrew's own Ruby:

brew install tomtom87/portage/portage

Or with RubyGems, on any Ruby ≥ 3.2, adding only the adapters you want:

gem install portage-cli

The gem pulls in portage-ucp, portage-ucp-client, and portage-ucp-journal as dependencies. Upgrade later with brew upgrade portage or gem update portage-cli; neither touches ~/.portage. If you have both, whichever portage comes first on PATH wins; which -a portage shows which, and portage doctor warns when a copy you didn't mean to run is shadowing the other. On Linux, stored payment tokens need secret-tool from your distro (e.g. libsecret-tools). See the CLI reference for details.

Then set your shipping address (the country at least: without it some stores report in-stock items as out of stock) in ~/.portage/.env, which portage loads on startup, and check your setup.

~/.portage/.env:

PORTAGE_SHIP_STREET="1 Main St"
PORTAGE_SHIP_CITY="Erie"
PORTAGE_SHIP_COUNTRY="US"
PORTAGE_SHIP_POSTAL_CODE="16501"
chmod 600 ~/.portage/.env
portage doctor

!!! warning "Only ~/.portage/.env loads automatically" A .env in the current directory is never loaded. A cloned repo's .env could otherwise route your traffic through its proxy or point purchases at another store without you noticing. Use a project file on purpose with PORTAGE_ENV_FILE=.env. Why.

doctor reports how Portage was installed, the Ruby it runs on and which adapters load, then lists anything to fix.

Can Portage buy from this store?

portage check https://your-shop.example

Prints a verdict (automated, webmcp, handoff or unsupported), the detected platform and a next step. It sends plain GET requests only, never contacts a hand-off-only host, and exits 0 only for automated and webmcp. Add --json for the full report, and see Checking any store.

Search only — no charge

portage find --query "usb-c cable" --max-price 20 --json

find never touches payment or checkout — it only resolves candidate stores and probes their /.well-known/ucp manifest. Safe to run freely.

Resolve price/path without completing checkout

portage buy --query "usb-c cable" --max-price 20 --dry-run --json

Same search-and-probe pipeline as find, but shaped like a real buy call so you can see the price/path resolution buy would use — --dry-run stops before checkout, no charge either way.

--query is optional when the only positional argument doesn't look like a URL (no scheme://, no . in it) — portage buy "usb-c cable" is shorthand for portage buy --query "usb-c cable". A bare arg that does look like a URL/domain (shop.com, https://shop.com) is still read as the store to buy from, same as always.

portage buy https://some-ucp-store.example --query "hoodie" --dry-run

Point buy at a known store URL directly and it skips search entirely, going straight to manifest probe. Against a domain with no UCP manifest (or that doesn't resolve), it fails clean:

No automated path — visit https://some-ucp-store.example yourself. (source: none)

Why generic queries return nothing

find/buy --query resolve candidate stores through search backends (portage-cli/lib/portage/cli/search_backends.rb), tried in this order:

  1. Allowlist — ~/.portage/stores.yml or PORTAGE_STORES env var. No key, no network call, query-independent (always considered).
  2. DuckDuckGo Instant Answer API — no key, always available?, so it's the default when nothing else is configured.
  3. Brave Search — needs BRAVE_SEARCH_API_KEY.
  4. Google Programmable Search — needs GOOGLE_CSE_KEY + GOOGLE_CSE_CX.

With no keys and no allowlist file, DuckDuckGo is the only backend that runs. And DuckDuckGo's Instant Answer API is an entity resolver, not a web search — it answers "what official site does this named thing have," not "what stores sell this category of thing." Confirmed directly against the API:

curl -s "https://api.duckduckgo.com/?q=usb-c+cable&format=json&no_html=1&no_redirect=1"
# => "Results": [], "RelatedTopics": []

curl -s "https://api.duckduckgo.com/?q=ugreen&format=json&no_html=1&no_redirect=1"
# => full Wikipedia company entity (founder, industry, ticker...) but still "Results": []

Generic category queries ("usb-c cable") and even well-known brand names (ugreen, belkin, apple) routinely come back with an empty Results field — DuckDuckGo just doesn't populate the official-site link reliably. A single unambiguous brand query can work:

portage find --query "burton snowboards" --json
{
  "query": "burton snowboards",
  "candidates": [{ "origin": "https://www.burton.com", "source": "duckduckgo" }],
  "stores": [],
  "offers": [],
  "message": "Checked 1 store(s); none of them speak UCP."
}

DuckDuckGo resolved burton.com, find probed it for a UCP manifest, found none — a clean, correct empty result (burton.com just doesn't speak UCP), not a search failure. This confirms the full pipeline works end to end: search → candidate → manifest probe → graceful no-match.

Getting real results

DuckDuckGo-only is a keyless fallback, not a real search engine. To make find/buy --query actually useful for category queries:

  • Set BRAVE_SEARCH_API_KEY — real web search, free tier available.
  • Set GOOGLE_CSE_KEY + GOOGLE_CSE_CX — Google Programmable Search.
  • Seed ~/.portage/stores.yml (bare YAML array of URLs) or PORTAGE_STORES (comma-separated) — costs no network call, always considered regardless of query, good for stores you already trust.

portage doctor flags a DuckDuckGo-only setup itself (an info-level search_backend finding, doesn't fail the run) so this is visible before you hit a confusing empty result, and an empty find/buy --query result now names the fix inline:

No candidate stores came back from duckduckgo for "usb-c cable". DuckDuckGo's
free API only resolves specific brand/product names, not open-ended search —
set BRAVE_SEARCH_API_KEY or GOOGLE_CSE_KEY/GOOGLE_CSE_CX for real web search
(see `portage doctor`).

All of these, and the PORTAGE_SHIP_* address, are listed in the repo's .env.example.

Seeding the allowlist from a directory site

https://ucptools.dev/directory lists ~100 stores with a self-assigned "AI commerce readiness grade." Treat that grade as noise, not signal: the site grades itself Grade A, and it's not verifiable — a grade doesn't mean the store actually serves a UCP manifest. It has no API either, just an HTML page.

Don't copy its list on faith. Instead, pull the raw domain list off the page and probe each one directly for a real manifest:

while read -r d; do
  code=$(curl -s -o /tmp/resp.json -w "%{http_code}" --max-time 4 "https://$d/.well-known/ucp")
  if [ "$code" = "200" ] && grep -qi '"ucp"' /tmp/resp.json; then
    echo "$d"
  fi
done < domains.txt

Out of the ~99 domains listed, 38 answered with a real manifest (all on Shopify, "version":"2026-08-25") — everything from Allbirds and Glossier to Skims and The Body Shop. The other ~60 (Instacart, Trader Joe's, Tesco, Whole Foods, etc.) returned nothing at /.well-known/ucp — not UCP stores regardless of what grade the directory gave them.

Only the verified 38 went into ~/.portage/stores.yml. Once seeded, the Allowlist backend picks them up automatically — no key, no network call to resolve candidates, and (being query-independent) every find/buy --query call considers all of them; each store's own catalog search decides whether it stocks the thing you asked for:

portage find --query "usb-c cable" --max-price 20 --json
# => 38 candidates, source: "allowlist" — probed regardless of query,
#    each store's catalog decides fit

Building a local store index

A fresh install only knows the stores in stores.yml or whatever a search backend returns for one query. portage index gives find a standing, local list to route queries to instead, built from sources you can read:

portage index sources                     # what each source fetches, and its file path
portage index build                       # every default source (Shopify's open catalog, your stores.yml)
portage index show --stores --json
portage index add https://thelightyard.co.uk --crawl   # read one store's catalogue too
portage index search "bathroom pendant"                # search it locally, no request

Stored in ~/.portage/index/index.sqlite3, never in git, and never carrying a price or stock field — those stay live. find also merges in the repo's own published known-stores list automatically (over jsdelivr, cached and refreshed periodically) even before you run index build yourself. The index is untrusted data on the same footing as any other find candidate: it never feeds policy set --allow and never lets --yes complete a purchase without you naming the store. Full reference: CLI reference § Local store index.

Seeding the index from your own browsing history

portage browser import --dry-run --json

Reads your browser's bookmarks and history, reduces them to domains, and keeps only the ones that answer /.well-known/ucp (or are already known). Nothing is written until you review the kept[] list and re-run with --yes:

portage browser import --yes --exclude some-domain-you-declined.example

Never reads cookies, saved passwords, or browser autofill data — only bookmarks/history files, and only bookmarks/history. Details: CLI reference § Browser import.

The setup wizard

On a real terminal, portage setup walks through the steps above interactively — shipping address, search API keys, retailer offer source keys, the agent profile, browser import, index build, spending caps and hand-off target — one skippable step at a time, never echoing a secret back:

portage setup

Piped, from CI, or with --json, it's exactly portage doctor --json's read-only report — always safe to run non-interactively.

Picking and approving at the terminal

find prints a search_id and a ref for each offer (--json has them as search_id and offer_ref). Two commands turn those into the person's decisions. Run them yourself, at a terminal, and they ask you on /dev/tty.

Pick the store. portage pick lists the latest search's offers, plus a last choice, "Compare an offer across stores":

$ portage pick
  1. https://shop.example — Cold Brew — 24.00 USD
  2. https://other.example — Cold Brew — 22.00 USD
  3. Compare an offer across stores
Pick an offer (1-3, v N to view, Enter to cancel): v 2
Opened https://other.example/products/cold.
Pick an offer (1-3, v N to view, Enter to cancel): 2
[picked] Picked Cold Brew from https://other.example — next: `portage buy --offer of_bbbbbb --dry-run`.

v 2 opens offer 2's product page in your browser and asks again. Viewing is never an answer. Only pages on the offer's own store host are opened. Enter on its own cancels. Choosing "Compare" asks which offer, runs portage compare on it, then shows the pick again over its results.

Price it, then approve the total. A dry run saves a quote and prints its quote_id (--json has it as quote_id):

$ portage buy --offer of_bbbbbb --qty 2 --dry-run
$ portage approve qt_5c0d1e2f3a4b
Cold Brew — https://other.example
  qty 2, total 44.00 USD — https://other.example/products/cold
Buy 2 × Cold Brew from https://other.example for 44.00 USD? [y/N, v to view] y
[approved] Approved 2 × Cold Brew from https://other.example for 44.00 USD — next: `portage buy --quote qt_5c0d1e2f3a4b --yes`.
$ portage buy --quote qt_5c0d1e2f3a4b --yes

v opens the product page and asks again. buy --quote ... --yes buys exactly that quote. If the price has gone up since the dry run it refuses (quote_changed) and nothing is charged; each quote is used once.

The approval policy. --require-approval says what a real buy --yes needs:

portage policy set --require-approval person   # only a yes you type yourself counts
portage policy set --require-approval any      # the default: yours, or one an agent relays
portage policy set --require-approval off      # `--yes` alone buys
portage policy show                            # ends with `require_approval: any (default)`

Lowering it (for example person to off) asks for a yes at the terminal. It's stored in ~/.portage/policy.json. Under any or person, a buy --yes with no approved --quote doesn't buy: it dry-runs and reports needs_approval.

!!! warning "Upgrade note" Before this setting, --yes alone bought. Under the default any it no longer does. To restore the old behaviour, run portage policy set --require-approval off from a terminal.

person raises the bar but isn't a hard guarantee: an agent with a shell can edit ~/.portage/policy.json or the quote files, or run its own terminal. For agents, see Agentic flow. The "Compare" choice uses your proxy settings from the environment and config.json; pick has no --proxy flags.

Hand-off: how a purchase actually finishes (Tiers A/B/C)

Most stores don't let portage buy complete payment itself. It builds the cart/checkout it can, then hands off:

portage buy https://some-shop.example --query "mug" --yes --handoff-target default
  • Tier A, default (the default): opens the checkout in your own browser — your login, saved address and saved card all apply.

  • Tier B, profile (opt-in): drives a dedicated Portage browser profile instead, up to the point of payment:

    portage browser profile open   # once, to sign into your shopping sites
    portage buy https://some-shop.example --query "mug" --yes --handoff-target profile

    Never your default browser profile, and limited to the store's own domain plus its checkout host — Portage never touches a payment field or clicks pay.

  • Tier C, hand-off only: Amazon (every marketplace) and any host you add to ~/.portage/config.json's handoff_only_hosts are never sent a request at all — portage buy opens the page (or a cart-add/search URL) and reports outcome: "handoff_only" with a legal_notice, since these sites restrict automated purchasing agents in their own terms.

Portage is open-source software provided as-is, without warranty of any kind (MIT) — how you use it on any given site, and compliance with that site's terms, is your own responsibility. Full detail, including the agent:<name> hand-off target for an approved external agent: CLI reference § Tiers.

Running behind a proxy

portage buy --query "hoodie" --dry-run --json \
  --proxy http://user:pass@proxy.internal:3128 --no-proxy localhost,127.0.0.1

--proxy/--no-proxy (and their PORTAGE_PROXY/PORTAGE_NO_PROXY env equivalents, and ~/.portage/config.json's "proxy" section) are the Portage-specific way to configure this — see proxy.md for corporate egress, a rotating residential pool, an API gateway, mitmproxy for debugging, and nginx/Cloudflare in front of the MCP/WebMCP endpoints, and the CLI reference for the full flag/env reference. Below that layer, the plain http_proxy/HTTPS_PROXY/NO_PROXY env vars are still the fallback for any route left unconfigured — see plain proxy environment variables for how they're read. portage doctor reports the effective proxy per route, credentials redacted.

Known issue: Client.discover can't parse real 2026-08-25 manifests

Testing buy against a handful of the verified 38 (Casper, Glossier, Olaplex — all real, live UCP manifests) all came back "No automated path — visit ... yourself." even though curl https://casper.com/.well-known/ucp returns a full, valid manifest with checkout/cart/catalog capabilities and a Google Pay handler.

Root cause: Portage::Ucp::Client.fetch_manifest (portage-ucp-client/lib/portage/ucp/client.rb) expects the older flat manifest shape — top-level services as an array of {transport, endpoint} objects, top-level capabilities as an array of {name} objects. The manifests these stores actually serve nest everything one level deeper under an "ucp" key, with services/capabilities as hashes keyed by service/capability name:

{
  "ucp": {
    "version": "2026-08-25",
    "services": { "dev.ucp.shopping": [{ "transport": "mcp", "endpoint": "..." }] },
    "capabilities": { "dev.ucp.shopping.checkout": [...], "dev.ucp.shopping.cart": [...] }
  }
}

Array(manifest["services"]) reads nil off the mismatched top level, so mcp_endpoint never finds an endpoint and raises DiscoveryError — silently swallowed by Buy#discover's rescue ... nil, which is why it looks like a normal "not a UCP store" result instead of an error. Confirmed directly:

ruby -Ilib -e '
require "portage/ucp/client"
Portage::Ucp::Client.discover("https://casper.com")
'
# => Portage::Ucp::Client::DiscoveryError: manifest has no mcp service entry to connect to

This isn't a per-store problem — every store in the verified allowlist will hit the same wall until fetch_manifest/mcp_endpoint/capability_names are updated to read the "ucp"-nested, hash-shaped manifest that real stores (Shopify's rollout, as of 2026-08-25) actually serve.