CLI reference¶
Full reference for the portage command, shipped by the portage-cli gem — every
subcommand, flag, and environment variable it reads, single-sourced from the gem's own
README.
portage-cli¶
Ships the portage executable — one CLI command to buy from any store, native
UCP or not.
portage buy <url>:
- 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. - 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. - 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 buy with no URL runs that search and then buys the offer you pick.
Already have the item and want to know where else it's sold? portage compare
resolves a product you name by URL + product id, then runs the same
find pipeline against its title and ranks the results by how confident the
match is:
Depends on portage-ucp
(for platform detection via Resolver),
portage-ucp-client
(for the actual buy calls), and
portage-ucp-journal
(for portage-console's read-only view of local purchase state — see below).
No single adapter gem is a hard dependency — install whichever
portage-ucp-<platform> gem matches the store you're integrated with, if any.
Installation¶
Homebrew (macOS and Linux) — recommended for using the CLI:
The formula installs portage-cli plus every adapter gem (Shopify, Wix,
WooCommerce, BigCommerce, Magento, Etsy, Instagram, WebMCP, Decision) into
its own directory, running on Homebrew's own ruby, so it doesn't depend on
or change whichever Ruby you use for anything else. It gives you portage
and portage-console.
RubyGems — on any Ruby ≥ 3.2, or when you only want some adapters:
In an app's Gemfile instead:
Upgrading¶
- Homebrew:
brew upgrade portage. Your config and data in~/.portage(policy, payment-method metadata, transaction log, order ledger,config.json) are left alone, as they are bybrew uninstall. - RubyGems:
gem update portage-cli(and any adapter gems you added).
Portage has no self-update command, by design: whichever tool installed it owns upgrades.
Linux: stored secrets need secret-tool¶
On Linux, stored payment tokens (portage payment enroll) and proxy
passwords (password_ref) live in the Secret Service (GNOME Keyring,
KWallet) via the secret-tool command. It comes from your distribution,
not from the formula or the gem:
Without it (or without a live D-Bus session, e.g. over SSH or in CI),
Portage uses the headless tier: the token comes from
PORTAGE_PAYMENT_TOKEN and nothing is stored locally. On macOS the
Keychain is used and nothing extra is needed.
Two copies on PATH¶
A gem install copy and a Homebrew copy can both be installed, and your
shell runs whichever portage comes first on PATH. A common case is an
old gem copy in a mise, rbenv, asdf or rvm Ruby's bin, which then keeps
running after brew install or brew upgrade. portage doctor warns
about this. To check:
To fix it, keep one copy: gem uninstall portage-cli (with the Ruby that
owns the gem copy active) to use Homebrew's, or brew uninstall portage to
use the gem's. Or reorder PATH so the one you want comes first.
Usage¶
portage buy <url> --query "..." [--qty N] [--payment-token TOKEN] [--product-id ID]
[--yes] [--dry-run] [--auto-open|--no-auto-open]
[--notify-webhook URL]
[--handoff-target default|print|profile|agent:NAME]
[--decision-backend jev|laya] [--min-confidence N] [--json]
[--wait [--wait-timeout DURATION|off]]
portage buy --offer REF [--qty N] [--yes] [--dry-run] ...
portage buy --quote QUOTE_ID --yes [--json] ...
portage buy --query "..." [--store URL] [--max-price N] [--limit N] ...
portage find --query "..." [--max-price N] [--limit N] [--json]
portage compare <url> --product-id ID [--id VALUE ...] [--results N]
[--max-price N] [--json]
portage check <url> [--json]
portage pick [--search LAST|SEARCH_ID] [--via auto|tty|agent] [--json]
[--choose REF | --compare REF | --view REF]
portage approve QUOTE_ID [--via auto|tty|agent] [--relayed-yes | --view] [--json]
portage history [list] [--purchases|--searches] [--limit N] [--json]
portage history clear [--purchases|--searches]
portage payment list [--json]
portage payment enroll <url> [--label NAME] [--json]
[--scope-merchant HOST ...] [--scope-max-amount N] [--scope-currency CUR]
portage payment set-default <id>
portage payment remove <id>
portage payment freeze <id>
portage payment revoke <id>
portage policy show [--json]
portage policy set [--per-transaction-cap N --currency CUR]
[--rolling-cap N --rolling-window-seconds N --currency CUR]
[--velocity-count N --velocity-window-seconds N]
[--allow HOST ...] [--clear-allowlist]
[--require-approval person|any|off] (lowering asks at a terminal)
portage orders reconcile [--checkout ID] [--json]
portage index build [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
portage index refresh [--sources a,b] [--queries FILE] [--dry-run] [--export DIR] [--json]
portage index show [--stores|--products] [--json]
portage index add <url> [--json]
portage index remove <host> [--json]
portage index sources [--json]
portage browser import [--browser chrome|edge|brave|arc|firefox|safari] [--profile-root DIR]
[--history-days 90] [--include-product-pages] [--max-probes 200]
[--exclude HOST,HOST] [--dry-run] [--yes] [--json]
portage browser profile init|open|status [--browser chrome|edge|brave|arc] [--port N]
[--url URL (open only)] [--json]
portage doctor [--require FILE] [--adapter CLASS_NAME] [--json] # alias: configure
portage setup [--json] # interactive wizard on a TTY; --json/no TTY: today's doctor report
portage generate adapter NAME [--dir DIR]
portage generate agent-profile [--out FILE] [--key-out FILE] [--rotate]
portage --version
buy/find/compare/doctor/payment enroll (the network-touching commands —
orders reconcile doesn't take these) also accept:
[--proxy URL] [--proxy-mode forward|gateway] [--proxy-header "Name: value"]
[--no-proxy HOSTS] [--proxy-route ROUTE=URL|direct] [--proxy-chain URL,URL,...]
[--proxy-passthrough HEADER] [--proxy-ca FILE] [--no-env-proxy]
See "Proxy" below.
--query— search term. Against the store's catalog when you name a store, against the search backends when you don't.--qty— quantity, default1.--payment-token— a tokenized payment credential (never a raw card number —PaymentTokenGuardin the core gem rejects those before they reach the wire). Omit for--dry-runor to just browse, or to fall back to whateverportage paymenthas on file as the default (see "Payment" below) — the flag always wins when both are present.--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 (400means 400), compared per offer in that offer's own currency. No FX conversion. Applies per unit, to the search and to the store's own catalog once one is settled (a URL,--store, or a picked offer): nothing priced above it is checked out, even with--product-id. A product with no price is still eligible.--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.--decision-backend— opt into the confidence gate (see "Decisions" below):jevorlaya. Defaults toPORTAGE_DECISION_BACKEND; unset means off.--min-confidence— the gate's threshold,0.0–1.0. Defaults toPORTAGE_MIN_CONFIDENCE, then0.8. The flag is always checked; the env var is read (and checked) only while a backend is selected, so a stale value can't block a buy that doesn't use the gate.--handoff-target default|print|profile|agent:NAME— where a dead-end checkout's link goes. See "Hand-off targets and hand-off-only hosts" below.--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. A flag that can't be
used (an unreadable value, an out-of-range threshold) stops the buy before
anything runs: on stderr normally, or as a JSON report with outcome:
"invalid_option" under --json.
Check¶
portage check <url> [--json] answers "can Portage buy from this store, and how?".
It checks for a native /.well-known/ucp manifest, detects the platform, notes
whether its adapter gem is installed and which env vars are missing, and looks for
WebMCP tools, using the same hand-off-only rules as buy. verdict is
automated, webmcp, handoff or unsupported, with a plain-English
next_step. Exits 0 for automated and webmcp. It sends plain GETs only, and
never contacts a hand-off-only host. WebMCP is read only from a tab your Portage
browser profile already has open on the store; it never launches a browser.
Takes the --proxy* flags.
Compare¶
portage compare <url> --product-id ID finds other stores selling the same
item you already have. It resolves the named product, then runs find's own
candidate-discovery/probe/rank pipeline against the product's title, scoring
each surviving offer instead of treating them all as equally confident hits:
--product-id— required. The item to compare, at the store you name.--id VALUE— repeatable. A SKU, barcode (UPC/EAN/GTIN), or MPN you already know, matched case-insensitively against every candidate's own identity values. There's no way to tell the matcher which kind of identifier you passed — the wire format doesn't distinguish them — so it doesn't pretend to; passing one just adds it to the matching corpus. When omitted, the origin product's own first variant sku/barcodes are used instead.--results N— how many ranked offers to return, default 5. Applies after ranking, not before — a truncated result is always the worst N dropped, never an arbitrary N. The underlying probe cap (candidate origins checked, not results returned) staysfind's own limit and isn't exposed on this subcommand.--max-price— same semantics asfind's.
Every offer carries a match: tier so a caller never mistakes a coincidence
for a confirmed match:
| Tier | Means |
|---|---|
confirmed |
Origin and candidate share a barcode value (UPC/EAN/GTIN) — the one identifier the spec treats as globally unique. |
likely |
Origin and candidate share a SKU, or an explicit --id hit landed on the candidate — both are "some string matched", not "a global identifier matched", so they share one tier rather than a false precision gradient. |
unconfirmed |
Same search query, nothing shared. Could be the same item; could just have a similar title. |
The origin store itself is excluded from results, matched by host (not by
raw origin string), so an http:///https:///trailing-slash variant of your
own store's URL doesn't show up as a "competitor." A www. variant is
treated as a different host, same as find's own candidate dedupe — worth
knowing if your store answers on both.
Known limitation: recall, not ranking, is the ceiling. Compare searches
the backends using the origin product's own title — a store-specific
marketing string. If a backend never surfaces the competitor for that title,
no amount of tiering helps; every offer that does come back may be
unconfirmed because nothing more specific was searched. There's no
barcode/SKU-keyed second search pass yet.
Catalog-price only. No create_checkout step runs against any candidate
store — ranking uses each store's listed price, never a landed price
(shipping/tax included). Verifying the actual cheapest landed price would
mean starting a checkout on a store the shopper hasn't chosen, which risks
abandoned carts on someone else's site; left out of scope for now.
History¶
Every portage buy that creates a checkout is logged locally to
~/.portage/history.json as a purchase, whatever came of it. Each entry
carries the report's outcome (purchased, dry_run, policy_blocked,
...), what the checkout held (items), its total, and, when it wasn't
completed, the checkout_url to finish it. "What did I already buy" is
the entries whose outcome is purchased. A buy that never reached a
checkout (no match, browse-only, a dead end) is logged as a search at that
store instead, as is every find, compare, and the search behind a
buy with no URL. The most recent 200 entries of each are kept.
portage history # both lists, most recent last
portage history list --purchases # just purchases
portage history list --searches --limit 20
portage history clear # wipe both
portage history clear --purchases # wipe just one
This is a local convenience cache, not an audit log — portage history clear
deletes it outright, and there's no server-side record.
Payment¶
Card-on-file storage for --payment-token, so an autonomous agent can
complete a checkout without a human handing over a fresh token every time
(docs/plans/agentic-payments.md Phase 1). No raw card number ever touches
this process — enrollment is a browser handoff to the gateway's own hosted
setup page:
portage payment enroll https://your-shop.example --label "Ops card"
# → prints a setup_url; visit it, enter the card there, this process polls
# until the gateway hands back a token, then stores it.
portage payment list
portage payment set-default <id>
portage payment freeze <id> # blocks spend, keeps the enrollment
portage payment revoke <id> # deletes the token and the enrollment
portage payment remove <id> # same as revoke — no processor-side
# "invalidate this token" call to differ by
--scope-merchant/--scope-max-amount/--scope-currency bind a Phase 2
policy scope to the token at enrollment time, rather than after the fact:
portage payment enroll https://your-shop.example --label "Ops card" \
--scope-merchant your-shop.example --scope-max-amount 5000 --scope-currency USD
Written to Policy keyed by the same token_ref PolicyGuard derives from
the token at charge time — enrollment is the only place a scope gets
attached to a specific token; portage policy set below only touches the
global caps/velocity/allowlist, not per-token scopes.
Storage picks the strongest tier your platform actually has, in order, with no homegrown fallback store of its own:
- macOS Keychain, via the
securityCLI. - Linux Secret Service (GNOME Keyring/KWallet), via
secret-tool— only when a D-Bus session is actually live. - Headless — no local storage at all. The token is
PORTAGE_PAYMENT_TOKEN;list/enroll/freeze/etc. don't apply, since there's nothing local to manage.
Local policy guards agent mistakes, not a compromised agent. Anyone
running as the local user can read/edit ~/.portage/payment_methods.json or
the Keychain/Secret Service entry directly — this is a convenience store, not
a security boundary. The real backstop against a rogue or compromised agent
is an issuer-side limit (a virtual card via Stripe Issuing, Privacy.com,
etc.), not anything in this gem.
Policy¶
portage policy show/set manage the Phase 2 policy file
(Portage::Ucp::Policy, checked by PolicyGuard on every complete_checkout)
— top-level caps, velocity, and a merchant allowlist that apply regardless of
which token is spending:
portage policy show
portage policy show --json
portage policy set --per-transaction-cap 10000 --currency USD
portage policy set --rolling-cap 50000 --rolling-window-seconds 86400 --currency USD
portage policy set --velocity-count 5 --velocity-window-seconds 3600
portage policy set --allow shop.example.com --allow other-shop.example.com
portage policy set --clear-allowlist
Each --* group is applied independently — portage policy set --allow
shop.example.com touches only the allowlist, leaving caps/velocity as they
were, so caps and the allowlist can be configured in separate invocations.
An empty policy (nothing ever set) means every check passes; this is an
opt-in guardrail, not a default-deny one. Per-token scopes (merchant/amount
limits bound to one enrolled card) are set via portage payment enroll
--scope-* above, not here.
portage policy set --require-approval person|any|off (default any) sets what a
real buy --yes needs: off is --yes alone; any needs --quote QUOTE_ID for a
quote approved with portage approve (by the person, or relayed by an agent with
--relayed-yes); person needs the person's own yes at a terminal. Otherwise the run
is a dry run that returns needs_approval. Lowering the level asks for a yes at a
terminal. Stored as require_approval in ~/.portage/policy.json. It raises the bar
against an agent but isn't a hard guarantee: a process with a shell can edit that file
or the quote files in ~/.portage/quotes/. The whole flow (find, pick, buy
--offer --dry-run, approve, buy --quote --yes) is in the
CLI JSON reference and the
tutorial. Upgrade
note: under the default any, buy --yes with no approved --quote no longer buys;
restore the old behaviour with portage policy set --require-approval off from a
terminal.
Tiers: how a purchase actually finishes¶
Most stores don't let a third-party agent complete payment. portage buy
never pretends otherwise — it builds the cart/checkout it can, then hands
off through one of three tiers, from least to most involved:
| Tier | What | Default | Guardrail |
|---|---|---|---|
| A | Hand off to your own default browser; optionally seed the store index from your bookmarks/history (portage browser import) |
Hand-off on; import opt-in | Domains only; you see and approve the imported list; nothing leaves the machine |
| B | A dedicated Portage browser profile drives the cart via WebMCP, then hands off there for you to pay (portage browser profile) |
Off | Never your default profile; a domain allowlist (the store plus its checkout host); stops at payment — you click pay, your browser's own card autofill fills it |
| C | Hand-off-only hosts (Amazon, and any host you add) — Portage opens the page or a search/cart-add URL and you buy | On by default, host list is yours to edit | No scraping, no page reads, no UCP probe — just a URL, built not fetched |
Never, in any tier: Portage reading your browser's password, cookie or autofill store; Portage attaching to your default browser profile; Portage solving or bypassing a CAPTCHA; card data passing through Portage.
Hand-off targets and hand-off-only hosts¶
Most checkouts end in a hand-off, not a purchased outcome — see the next
section. --handoff-target default|print|profile|agent:<name>
(PORTAGE_HANDOFF_TARGET, or ~/.portage/config.json's "handoff_target")
decides where that link goes:
default(Tier A) — opens it in your own browser. Today's behaviour:--auto-open/--no-auto-open,PORTAGE_AUTO_OPEN_CHECKOUT.print— just reports the URL.profile(Tier B) — drives the dedicated Portage browser profile (see "Portage browser profile" below) instead of your own. With no profile attached (not opened yet, orportage-ucp-webmcpisn't installed), it reports that and falls back to reporting the link.agent:<name>— hands the checkout URL and cart summary (items, qty, total, store — the same JSON--notify-webhooksends) to an external agent you've approved once in~/.portage/config.json's"handoff_agents"(a command, run with a scrubbed environment and the payload on stdin, or anhttpswebhook — never invoked unless"approved": true, and never given credentials, payment tokens or shipping details beyond what the checkout URL already holds).
An unrecognized value is a usage error (invalid_option under --json),
checked before the buy starts.
Amazon (every marketplace TLD), walmart.com, ebay.com and bestbuy.com
(unconditionally — no adapter, no UCP for any of them to opt back into), and
any host in ~/.portage/config.json's "handoff_only_hosts" are Tier C,
hand-off only: portage buy never sends that host a request at all — no
UCP probe, no page fetch, no cart — it opens the page (or a cart-add/search
URL when a product id is known) and you buy it yourself. Absent that config
key, the default list is every Amazon marketplace; once present, your list
is the list — drop Amazon or add another host, and removing one only
changes the message, since there's no code here that automates a site
without UCP or WebMCP. find, index build and browser import all skip
probing a hand-off-only host too, though they may still list it as a
candidate you already know about. portage doctor reports the current
target and host list. Portage is open-source software provided as-is,
without warranty of any kind (MIT) — how it's used on any site, and
compliance with that site's terms, is your own responsibility.
Categories and routing¶
portage find classifies your query and a store's title/description/URL
slug against known-stores/categories.yml (the top two levels of Google's
published product taxonomy, ~200 nodes shipped in the gem;
~/.portage/categories.yml overrides or extends it). A tagged
stores.yml/index entry ({url:, categories: [...]}) only spends one of a
query's probe slots when its categories actually match — capped at 3 stores
per category and 12 total — so a large personal allowlist or a big local
index doesn't crowd out the store that actually sells what you asked for. An
entry with no matching category is still reached when you name it by host or
brand.
Local store index (portage index)¶
A fresh install only knows the stores you type into stores.yml or that a
search backend returns for one query. portage index gives find a
standing, local list of stores and products to route queries to instead,
built from sources you can read (portage index sources):
portage index build # every default source
portage index build --sources shopify_catalog,stores_file
portage index build --queries queries.txt # one query per line, instead of the built-in taxonomy sweep
portage index refresh # re-verify entries older than 7 days, add new ones
portage index show --stores --json
portage index show --products --json
portage index add https://some-shop.example
portage index remove some-shop.example
portage index sources # name, what each fetches, source file path
Stored at ~/.portage/index/{stores,products}.json, never in git and
never containing a price or stock field — those are always fetched live.
Each new origin gets exactly one /.well-known/ucp probe (capped at 500 new
probes per run), throttled, with progress output. Sources:
| Source | Fetches | Default |
|---|---|---|
shopify_catalog |
Merchant origins and product identities, one query per top-level taxonomy node, from catalog.shopify.com's open catalog |
on |
stores_file |
Your own ~/.portage/stores.yml |
on |
browser |
Whatever portage browser import (below) already saved — this source itself never reads a browser |
on, but yields nothing unless you've run browser import |
wikidata |
Retailers'/brands' official sites via a public SPARQL query | opt-in (--sources wikidata) |
webmcp_sweep |
Which WebMCP preset an origin matches, when a bridge is attached | opt-in, needs a bridge |
The index is untrusted data, on the same footing as any other find
candidate. It never feeds Policy#merchant_allowlist and never counts as
"you picked a store" for --yes — a search ranker (which an index entry
still is) never gets to complete a purchase on its own.
Known-stores, fetched, not built by you. The repo itself publishes
known-stores/{stores,products}.json — built the same way, just by the
maintainer — over jsdelivr's @main CDN. find uses it automatically
(cached at ~/.portage/index/known-*.json, refreshed on index
build/refresh or when doctor sees it's more than 7 days stale) even
before you ever run index build yourself; your own entries always win over
it on a conflict. index build --export DIR writes a PR-ready copy with
personal (browser-derived) entries stripped, for anyone who wants to
contribute a store they found to the shared list.
Browser import (Tier A)¶
portage browser import --dry-run --json
portage browser import --browser chrome --history-days 90 --max-probes 200
portage browser import --yes --exclude some-domain-you-declined.example
Reads your browser's bookmarks and history — Chromium family's History
(SQLite)/Bookmarks (JSON), Firefox's places.sqlite, Safari's
History.db/Bookmarks.plist (needs Full Disk Access on macOS; the command
explains the prompt and never works around it) — reduces every row to a bare
domain, and decides each one locally first against the hand-off-only list,
the local index and the known-stores cache before spending one of at most
--max-probes (default 200) /.well-known/ucp probes on an unknown one.
Kept domains are classified (page titles, bookmark folder names, URL slugs)
into the same categories find routes by, weighted by visit count.
Nothing is saved without your approval: a dry run (or any non-interactive
run without --yes) only shows what it would keep; --yes (after you've
reviewed the list, optionally with --exclude host,host for ones you don't
want) writes them to the local index as sources: ["history"]/["bookmark"]
entries. --include-product-pages (off by default) also keeps product page
titles/URLs as product entries.
Never reads cookies, saved passwords, or autofill data, on any browser —
only the two files named above, verified by a spec that opens a fixture
profile full of Login Data/Cookies/Web Data decoys and asserts none of
them were touched.
Portage browser profile (Tier B)¶
portage browser profile init # create the dedicated profile directory
portage browser profile open # launch it, remote debugging on
portage browser profile status
A dedicated Chromium-family profile (Chrome, Edge, Brave or Arc — Firefox
and Safari aren't supported for driving) under ~/.portage/browser/,
launched with remote debugging scoped to that profile only — never your
default one; Chrome 136+ refuses remote debugging on the default profile
anyway. Sign into your shopping sites there once. With
portage buy ... --handoff-target profile, the cart is built in this same
browser via WebMCP (when the store supports it) and the checkout opens there
for you to pay — driving is limited to a domain allowlist (the store being
bought from, plus its checkout host); navigating anywhere else stops the
run. Payment is filled by the browser's own saved-card autofill, triggered
by your own gesture — Portage never touches a payment field and never
clicks pay. Requires gem install portage-ucp-webmcp.
Retailer offer sources¶
Official, opt-in buyer-side APIs that add more real offers to portage
find, each gated on its own key set in ~/.portage/.env (or via portage
setup, below):
| Retailer | Env var |
|---|---|
| Walmart Affiliate API | WALMART_AFFILIATE_API_KEY |
| eBay Browse API (Buy It Now only) | EBAY_BROWSE_ACCESS_TOKEN (optional EBAY_MARKETPLACE_ID) |
| Best Buy Products API | BESTBUY_API_KEY |
Etsy Open API v3 (buyer-side findAllListingsActive) |
ETSY_LISTINGS_API_KEY |
| Amazon Creators API | AMAZON_CREATORS_ACCESS_TOKEN (optional AMAZON_CREATORS_MARKETPLACE) |
With none set, find behaves exactly as it did before these existed. Every
offer from any of these five still ends in hand-off — none of them has a
checkout portage buy can drive, so walmart.com/ebay.com/bestbuy.com are
hand-off only unconditionally and Amazon/Etsy follow the rules in "Hand-off
targets and hand-off-only hosts" above (Etsy only when your own seller
credentials aren't already configured for portage-ucp-etsy). portage
doctor reports which of the five are active.
The portage setup wizard¶
On a TTY, portage setup (or portage doctor/portage configure when
nothing is configured yet) walks through setup interactively, one skippable
step at a time, never echoing a secret back: shipping address, search API
keys, retailer offer source keys, the agent profile, browser import, index
build, spending policy caps, and hand-off target/hand-off-only hosts. Each
step delegates to the real command it configures — nothing is
reimplemented — so it behaves exactly like running that command yourself.
Under --json, or with no TTY on stdin (piped, CI, or a tool call), setup
never prompts: it prints exactly doctor --json's read-only report.
Orders reconcile¶
Nearly every real checkout portage buy can't finish itself hands the
shopper a link — requires_escalation, permission_denied,
no_payment_token, policy_blocked, low_confidence, or
checkout_mismatch. That's a pending purchase this process knows nothing
more about until something asks the store. portage orders reconcile
re-fetches each pending hand-off from the store and settles it once the
store itself reports a terminal status:
portage orders reconcile # every pending hand-off
portage orders reconcile --checkout chk_123
portage orders reconcile --json
A completed checkout settles complete — using the store's own total at
settle time, not the hand-off-time snapshot — records the order (when one
exists) and a journal entry, and is counted toward your spend policy's
rolling cap/velocity per handoff_spend_mode below. A canceled checkout,
or one that expires with no answer, settles failed. Anything still
in-progress, or not currently reachable, is left pending for the next run —
safe to put on a cron/launchd schedule; two overlapping runs never
double-settle the same record.
handoff_spend_mode (PORTAGE_HANDOFF_SPEND_MODE, config.json's
handoff_spend_mode) controls whether a reconciled shopper purchase counts
toward the caps portage policy set configures:
block(default) — counts like any agent-completed purchase.warn— recorded, but excluded from the cap/velocity math; still notifies when it would have pushed spend over the cap.precheck—block, plus a spend-cap check at hand-off time (portage buy, not reconcile): if this checkout's total would already exceed your cap, the URL is still printed but auto-open is suppressed.
--wait [--wait-timeout DURATION|off] on portage buy polls the same
reconciler right after a hand-off, instead of waiting for a separate
orders reconcile run. It backs off (2s → 30s, plus jitter) until the
checkout settles or its deadline passes — the earlier of
handoff_wait_timeout (PORTAGE_HANDOFF_WAIT_TIMEOUT, config.json; default
30m, off removes it) and the checkout's own expires_at. Ctrl-C, or
the deadline, leaves the record pending for a later portage orders
reconcile — it never settles from the wait itself. Under --wait --json,
stdout streams NDJSON — handoff, handoff_status on each store-reported
status change, handoff_settled — followed by the final report object;
plain --json without --wait is unchanged.
reconcile_notify (PORTAGE_RECONCILE_NOTIFY, config.json's
reconcile_notify) is a comma list of channels a settled hand-off notifies
on, from --wait or orders reconcile: webhook (default), macos (a
native notification), terminal (a printed line — forced on for a
plain-text --wait regardless of configuration), and journal (the order
snapshot journal write, already unconditional — listing it just documents
that).
Doctor¶
portage doctor # alias: portage configure
portage doctor --json
portage setup # same report, but interactive on a TTY — see below
Checks this machine's setup without touching the network (apart from probing any proxy you've configured). It first reports how Portage is installed, then lists anything that needs fixing:
install:homebrew(with the Cellar path) orgem(with the gem's path).runtime: the Ruby version and path it runs on, and theportage-cliversion.adapters: which first-party adapter gems load, and at which version. Missing adapters are expected on a gem install; on Homebrew, which bundles them all, a missing one is a warning.path: whichportageyour shell actually runs. Warns when another copy earlier onPATHshadows this one (see "Two copies on PATH" above).shipping: warns whenPORTAGE_SHIP_*is missing or incomplete (see "Shipping address" below), naming the variables to set.env_file: which env file was loaded (see "Environment file" below). Warns when other users can read it.- The confidence gate's backend, the User-Agent, and proxy settings.
index: local and known-stores index counts and staleness (see "Local store index" below).handoff: the current--handoff-targetdefault and the hand-off-only host list, plus the as-is/no-warranty disclaimer.retailer_offer_sources: which of the five retailer offer source keys are set, and a reminder that none of them can complete a purchase.- Seller-side checks against
Portage::Ucp.configuration(authenticator, rate limiter, signing keys, payment handlers). These only run when you pass--requirewith your app's initializer (Rails:--require ./config/environment) or--adapter. Without them doctor would only ever see the unconfigured defaults, so it just notes that it skipped them.
With --json the output is an array of findings, each with check,
message, level (warning or info) and, for the install checks,
details. Doctor exits 1 when there's at least one warning, else 0.
Proxy¶
buy, find, compare, doctor, and payment enroll accept the flags below,
resolved (per field, flag > env > ~/.portage/config.json) into a
Portage::Ucp::Support::ProxyConfig by Portage::Cli::ProxySettings — see
docs/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, worked through end to end.
| Flag | Meaning |
|---|---|
--proxy URL |
The default proxy's URL (http://user:pass@host:port). Overrides only proxy.default.url; every other configured field (no_proxy, routes, chains, ...) stays as set in config.json. |
--proxy-mode forward\|gateway |
The default profile's mode. forward (the default) is a standard HTTP proxy; gateway is a URL-rewriting gateway — see docs/proxy.md. |
--proxy-header "Name: value" |
Repeatable. Sent only to the proxy (on the CONNECT request, or the gateway request) — never to the real target. Authorization/User-Agent/X-Shopify-*-Access-Token/X-Payment-Token are refused here, always. |
--no-proxy HOSTS |
Comma-separated hostnames/suffixes/CIDRs (or a bare *) to always bypass the proxy for, whatever the route resolves to. |
--proxy-route ROUTE=URL\|direct |
Repeatable. Points one fixed route (store, search, notify, payment, platform, probe) at its own URL, or forces it direct regardless of default. |
--proxy-chain URL,URL,... |
An ad-hoc multi-hop chain for this run — each hop is forward unless prefixed gateway+https://.... Overrides the whole default profile (not just its url), since a chain has no single "url" field to merge with config.json's own. |
--proxy-passthrough HEADER |
Repeatable, server commands only. Allowlists an inbound request header to ride along on outbound calls made while serving it — see docs/proxy.md's passthrough section. Refuses a protected header name at parse time. |
--proxy-ca FILE |
A PEM file trusted in addition to the system store — for a TLS-intercepting corporate proxy or a debugging proxy like mitmproxy. |
--no-env-proxy |
Ignore HTTPS_PROXY/HTTP_PROXY/NO_PROXY (and their lowercase forms) entirely for this run — otherwise they're still the fallback for any route nothing else configured. |
Env vars (checked when the matching flag is absent, before config.json):
| Var | Matches |
|---|---|
PORTAGE_PROXY |
--proxy |
PORTAGE_PROXY_MODE |
--proxy-mode |
PORTAGE_NO_PROXY |
--no-proxy |
PORTAGE_PROXY_CA |
--proxy-ca |
PORTAGE_PROXY_HEADERS |
A JSON object of header name → value, merged under any --proxy-header flags (flags win on a name collision). |
Below all of the above, the standard HTTPS_PROXY/HTTP_PROXY/NO_PROXY (and
lowercase) variables are still the fallback for any route nothing here configures
at all — existing environments keep working unless --no-env-proxy is set. The
payment route is always forced direct unless a proxy is named for it
explicitly (--proxy-route payment=... or config.json's routes.payment) — it
never inherits a bare default/env proxy the way every other route does, so an
egress proxy nobody meant to hand payment tokens to never sees them by accident.
${ENV_VAR} inside a config.json header value (proxy_headers, forward_headers.add)
is expanded from the process environment at resolve time, so a secret can live in
the environment rather than the file itself. A proxy password can also live in
password_ref (resolved through the same macOS Keychain/Linux Secret Service tiers
portage payment uses, under its own portage-cli-proxy service name) instead of
plaintext in the URL — portage doctor warns when it finds a plaintext one anyway.
portage doctor reports, per route, what's effectively configured (credentials
redacted), whether each configured proxy/gateway is actually reachable, and flags
plaintext proxy credentials and an intercepting proxy (ca_file or gateway mode)
sitting on the payment route.
WebMCP (library use, opt-in)¶
portage buy from the shell has no browser of its own, so there's no CLI
flag for this — it's for a caller embedding Portage::Cli::Buy directly
alongside its own browser automation:
Portage::Cli::Buy.new(url: "shop.example", query: "mug",
webmcp_bridge: my_portage_ucp_webmcp_bridge).call
Given a portage-ucp-webmcp outbound bridge already pointed at a navigated
page, Buy tries it after native-UCP discovery finds nothing at that URL
and before falling back to a platform adapter. webmcp_checkout_mode
(PORTAGE_WEBMCP_CHECKOUT_MODE, config.json's webmcp_checkout_mode)
controls how it finishes: express_stop (default) builds the cart/checkout
and always hands off — reason express_stop, so --wait/orders
reconcile/handoff_spend_mode all apply exactly as they do to any other
hand-off. token isn't implemented yet; it reports
webmcp_token_unsupported rather than attempting completion. Requires
gem install portage-ucp-webmcp — not a hard dependency of portage-cli.
Against a page whose tools aren't a known platform preset, Buy falls back
to a schema-matched, shopper-confirmed mapping instead of giving up (see
portage-ucp-webmcp's README, "Stores that don't run Portage"). A mutating
match prompts on a real TTY with --json off. Under --json, or with no
TTY, it stops instead: outcome webmcp_mapping_unconfirmed, with the
proposal in tool_names_proposal.
No flag passes a mapping back. From the CLI, re-run the same command in
your own terminal without --json and answer the prompt. --dry-run is
enough, because the mapping is confirmed before the dry-run check. The
approved mapping is saved to ~/.portage/webmcp_mappings.json, and later
runs reuse it with no prompt.
Buy has no tool_names: keyword either. A library caller has two hooks.
webmcp_mapping_confirm: takes any object whose call(proposal, tools)
returns a tool_names: hash, or nil to stop with
webmcp_mapping_unconfirmed. Buy saves whatever hash it returns to
webmcp_mappings:, the store approved mappings are read from (default: a
Portage::Cli::WebmcpMappings on ~/.portage/webmcp_mappings.json).
Outside Buy, pass tool_names: to Portage::Ucp::WebMcp.connect
yourself.
dry_run: true against a page whose preset hands off through its own
checkout tool (Shopify's proceed_to_checkout) stops after the read-only
product search: nothing is added to the store's cart, the tab isn't sent to
checkout and nothing is autofilled. The dry_run report carries a would:
key with the line item, the hand-off tool and whether autofill would run.
Once the flow hands off to the store's own checkout page, the shopper can
opt into having it pre-filled: --autofill, or
PORTAGE_WEBMCP_AUTOFILL=approve / config.json's "webmcp_autofill":
"approve" (only that literal string turns it on — a generic truthy value
doesn't). Even opted in, nothing is typed until a second prompt shows the
shopper exactly which fields and values are about to be entered and they
approve it — refused outright under --json or no TTY. It only ever
touches contact email and shipping address (from PORTAGE_SHIP_*/the new
PORTAGE_SHIP_EMAIL) plus the cheapest shipping rate it can find; it never
touches a payment field and never clicks submit/pay, and the run still
always ends in the same express_stop hand-off. A headless browser (or one
that never says) reports autofill_needs_headed_browser; a CAPTCHA/
challenge on the page reports autofill_blocked — see
portage-ucp-webmcp's README, "Approved autofill of the store's checkout",
for the full field/outcome list.
Decisions¶
portage buy and portage find make their judgment calls
(docs/plans/system-one-decision-layer.md) through rules that live in
portage-ucp core: Support::OfferRanking, Support::Escalation and
PolicyGuard. portage-ucp-decision wraps the same modules as typed
verdicts, so ranking, escalation and the policy check answer the same way
whether or not it's installed. It's an optional plugin, not a dependency:
The confidence gate is the one feature that needs the gem, because the model backends live there.
Every portage buy report carries an outcome, so a script or agent loop
can branch on data rather than on the message text. The text output leads
with the same value, as [outcome].
outcome |
Meaning | checkout_url? |
|---|---|---|
purchased |
Completed. | no |
needs_confirmation |
Checkout ready; rerun with --yes. |
no |
dry_run |
Checkout created, --dry-run stopped it. |
no |
requires_escalation |
The store wants the shopper to finish. | yes |
checkout_mismatch |
Checkout differs from the request (PORTAGE_ABORT_ON_CHECKOUT_MISMATCH). |
yes |
no_payment_token |
No --payment-token and no default payment method. |
yes |
policy_blocked |
Your spend policy denied it; decisions.policy.reason says why. |
yes |
low_confidence |
The confidence gate held it. | yes |
permission_denied |
The store doesn't let this agent complete checkout. | yes |
handoff_only |
Tier C: Amazon or another hand-off-only host. legal_notice explains why; see "Hand-off targets and hand-off-only hosts". |
yes (built, never fetched) |
store_refused |
The store refused a cart/checkout call (e.g. sold out). | when the store gave one |
no_match |
Nothing in the store's results matched. | no |
browse_only |
The store has a catalog but no UCP checkout. | when an adapter offers a link |
agent_profile_missing, request_rejected, unsupported_wire_shape |
Native UCP setup problems; the message names the fix. | no |
adapter_error, adapter_misconfigured |
Your own-store adapter failed; the message quotes it. | no |
dead_end |
No UCP and no adapter for this store. | no |
invalid_option |
A flag was refused before the buy started (--json only); message says which. |
no |
Checkout reports also carry items (what the checkout holds, as opposed to
products, the search results) and the verdicts under decisions::
"decisions": {
"escalation": { "escalate": false, "reason": null },
"policy": { "allowed": true, "reason": null },
"confidence": { "proceed": false, "reason": "below_threshold", "confidence": 0.41,
"threshold": 0.8, "backend": "jev", "error": null }
}
Every verdict has a reason: null when the gate let the purchase through,
otherwise a string naming why it stopped it.
- escalation —
Support::Escalation. Arequires_escalationcheckout always escalates (reason: "requires_escalation"). A checkout that doesn't match the request escalates (reason: "mismatch") only underPORTAGE_ABORT_ON_CHECKOUT_MISMATCH; otherwise it's reported aswarnings. - policy —
PolicyGuard, run on your policy file (see "Policy" above) before any--yescompletion.reasonis the guard's own (per_transaction_cap_exceeded,rolling_spend_cap_exceeded,velocity_exceeded,merchant_not_allowlisted,token_scope_merchant,token_scope_amount,currency_mismatch, ortotal_unknown, below). It applies to remote native-UCP stores too. Before this check, only the own-store adapter flow's in-processDispatcherenforced the policy. Rolling caps and velocity limits count the completed purchases at that merchant in~/.portage/transactions.json.Dispatcherrecords own-store purchases there, andportage buyrecords remote ones: reserved before the store is asked to complete, then settled ascompleteonly when the store answers that it's purchased. - confidence —
ConfidenceGate, off unless--decision-backendorPORTAGE_DECISION_BACKENDnames a backend. Right before a--yescompletion it asks the backend whether the checkout matches the request and is safe to complete unattended. It sends the query, merchant, quantity, line items, totals and warnings, never the payment token. The gate fails closed, so three things hold the purchase: a score below the threshold (reason: "below_threshold"), a backend that can't answer ("backend_error"), and naming a backend withoutportage-ucp-decisioninstalled ("not_installed"). For the last two,errorsays what went wrong.jevneedsJEV_API_KEY;layaneedsLAYA_BRIDGE_SCRIPT(seeportage-ucp-decision's README).portage doctorflags whichever of these is missing for the selected backend. - policy also denies a checkout with no
totalline astotal_unknownwhenever a spend cap is set, rather than skipping the cap.
A blocked or held purchase hands the checkout off the same way an escalation
does (checkout_url, plus --auto-open/--notify-webhook if set). The
shopper can finish it themselves. The webhook body is JSON: event
(checkout_handoff), reason (the report's outcome), message (the
report's own sentence), store, query, checkout_url, checkout_id,
source, totals and warnings. Any 2xx counts as delivered; the POST
gives up after 5 seconds and reports notify_error instead.
portage find's offer order comes from Support::OfferRanking: buyable
first, then cheapest, then unpriced.
Environment file¶
portage and portage-console load ~/.portage/.env on startup, so your
shipping address, search keys and adapter credentials can live in one file
rather than your shell profile. .env.example at the repo root lists every
variable. Rules:
- Variables already set in your shell win over the file.
- Empty values are skipped, so blanks copied from
.env.exampleset nothing. KEY=value,export KEY=value,"double"(with\nand\"escapes) and'single'quotes all work;#starts a comment.PORTAGE_ENV_FILE=pathloads a different file instead, for examplePORTAGE_ENV_FILE=.envfor a project checkout's own.
Keep it private (chmod 600 ~/.portage/.env); portage doctor warns if
other users can read it.
Why ./.env is never loaded automatically¶
Many tools load a .env from whatever directory you run them in. Portage
deliberately doesn't, because portage spends money and handles payment
tokens, and the directory you happen to be in isn't something you chose
to trust. If it did, running portage inside a cloned repo, a downloaded
project or a shared folder would silently apply that directory's
settings, for example:
PORTAGE_PROXYplusPORTAGE_PROXY_CA, routing your store traffic through someone else's intercepting proxy, where they can read it;- a notify webhook that sends your checkout URLs and order details to someone else;
- store credentials or
PORTAGE_STORES, pointing purchases at a different store than you think.
So only ~/.portage/.env, a file you created in your own Portage
directory, loads automatically. To use a project's .env, name it on
purpose: PORTAGE_ENV_FILE=.env portage ..., after reading what's in it.
Shipping address¶
Set your shipping address in ~/.portage/.env (or your shell) rather than
a flag, the same way as adapter credentials. portage doctor warns until
the required ones are set:
PORTAGE_SHIP_STREET="1 Main St"
PORTAGE_SHIP_CITY="Erie"
PORTAGE_SHIP_REGION="PA" # optional
PORTAGE_SHIP_COUNTRY="US"
PORTAGE_SHIP_POSTAL_CODE="16501"
PORTAGE_SHIP_FIRST_NAME="Ada" # optional
PORTAGE_SHIP_LAST_NAME="Lovelace" # optional
PORTAGE_SHIP_PHONE="+1..." # optional
street/city/country/postal_code are required — a partial profile
(or one with empty values) is treated as no profile at all.
The variables are used in two ways:
- Native UCP stores (
buyandfindover HTTP) getPORTAGE_SHIP_COUNTRY,_REGIONand_POSTAL_CODE(plusPORTAGE_CURRENCYandPORTAGE_LANGUAGE, if set) as UCP buyer context, which a store uses to pick the market it prices and stocks in. These work on their own, without the full address. Without at least the country, a live Shopify store can report in-stock items as out of stock. - Your own store (
portage buy's adapter-credentials fallback, described at the top of this file), when its adapter supportsdev.ucp.shopping.fulfillment, also gets the full address as the checkout's shipping destination. Once the merchant prices shipping options against it,portage buyauto-picks the cheapest per fulfillment group; there's no interactive rate picker, since this drives one automated purchase. Native (non-adapter) UCP stores don't get the full address yet — seeportage-ucp's design log for why.
Buying without a URL¶
portage find and URL-less portage buy share one pipeline:
- Ask the backends which stores might sell it (see below).
- 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. - 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, optionally tagged {url:, categories: [...]}) or PORTAGE_STORES (comma-separated, untagged) |
Stores you already trust. Checked first, costs no network call. Tagged entries are routed by category (see "Categories and routing" above); untagged ones are always a candidate. |
| Index | ~/.portage/index/ (your own portage index build) plus the repo's published known-stores list |
Ranked between Allowlist and DuckDuckGo. Sits out entirely until an index actually exists — a fresh install's behavior is unchanged. Also matches a query against an indexed product by name or GTIN, not just a store. |
| 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_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.
Separate from all of the above, portage find/buy also merge in offers
directly from OfferSources — ShopifyCatalog (no key,
catalog.shopify.com's open catalog, always on) and the retailer offer
sources (opt-in, keyed — see "Retailer offer sources" above). These skip the
origin-probe step entirely, since a catalog result already names the
merchant's own product page.
Console¶
portage-console is a separate executable — a read-only IRB REPL over the
three local ~/.portage stores (design-log §22 item 6): TransactionLog
(reserve/complete records), OrderLedger (settled-order snapshots), and, if
you've wired journal: into your own Dispatcher (nothing in this gem does
that for you), the portage-ucp-journal gem's PurchaseJournal.
transactions # every reserved/completed transaction
transactions(shop: "your-shop.example")
find_transaction("idem_key_123")
transactions_since(Time.now - 86400, shop: "your-shop.example")
orders # every settled-order snapshot
find_order("order_123")
journal # empty unless a Dispatcher was built with journal:
Every result is passed through Portage::Ucp::Observability.redact before
it's returned — payment_token/oauth_token/Authorization and the PII
fields on an order's fulfillment destinations never print, even in a REPL you
trust. This is deliberately a local REPL, not the admin/web panel design-log
§16 also describes: a browser is a new place for a token to leak, and the
process holding a web panel also holds this machine's live platform admin
credentials in its env — problems a REPL run by whoever already has shell
access to this machine doesn't have.
Development¶
License¶
MIT — Copyright (c) 2026 Tom Whitbread.