# @fetchproxy/cli

> `fpx` — a one-shot CLI for the [fetchproxy](https://github.com/chrischall/fetchproxy) bridge: authenticated fetches and session reads through the user's signed-in browser tab, straight from a shell prompt or a skill.

`@fetchproxy/server` and `@fetchproxy/bootstrap` are libraries an MCP server embeds. `fpx` is the same bridge wrapped as a standalone binary — no Node process to write, no MCP server to run. Point it at a URL, get the response on stdout; point it at a storage bucket, get JSON back. Useful anywhere a skill or shell script needs one authenticated request and doesn't want to carry a whole MCP.

## Install

```sh
npm install -g @fetchproxy/cli
```

Or run it without installing:

```sh
npx @fetchproxy/cli profile list
```

Either way, the user also needs the fetchproxy browser extension (**Transporter**) installed — see the [top-level README](https://github.com/chrischall/fetchproxy#install).

## The profile model

`fpx` scopes every command to a named **profile** — a small JSON record describing one service: its declared domains, and (optionally) the cookie keys, storage keys, capture-headers, and IndexedDB scopes it's allowed to read. Profiles live at `$FETCHPROXY_CLI_HOME/profiles.json`, or `~/.fetchproxy/cli/profiles.json` if that variable is unset.

```sh
fpx profile add opentable --domain opentable.com
fpx profile list
# opentable    opentable.com
```

### Per-service identity

Each profile gets its own long-term identity, keyed by name: profile `opentable` connects to the bridge as `fpx-opentable`, with its identity keypair persisted at `~/.fetchproxy/identity/fpx-opentable.json`. This mirrors how a real `opentable-mcp` would identify itself — the extension's trust store, pair history, and capability grants are all keyed off that per-service identity, not off `fpx` itself. Running `fpx` against ten services looks, from the extension's point of view, like ten independent MCPs.

### Pair-code flow

The first time any verb connects under a given profile, the extension doesn't auto-trust it. `fpx` prints an 8-digit pair code to stderr:

```
$ fpx health -p opentable
fetchproxy pair code: 4829-1374 — approve in the Transporter extension popup
```

Open the Transporter popup, confirm the code matches, click Approve. Every subsequent command against that profile — from any verb — reuses the same trust record and skips the prompt.

### Re-pair on scope change

`fpx profile declare` widens a profile's scope (more cookie keys, a new capture-header, etc.). Because the extension's trust is keyed to `(identity, domains, capabilities)`, widening the scope changes the hello the next connection sends, and the extension treats that as a diff worth re-confirming:

```sh
fpx profile declare opentable --cookie oaid --capture-header x-csrf-token@www.opentable.com
# profile "opentable" scope updated — the next connect will ask you to re-pair (scope diff)
```

The next command against `opentable` prints a fresh pair code even though the identity is unchanged.

### Revocation

`fpx profile remove <name>` deletes the local profile entry and its identity file:

```sh
fpx profile remove opentable
# profile "opentable" removed — also revoke fpx-opentable in the Transporter extension popup
```

That only cleans up the CLI's side. The extension still holds a trust row for `fpx-opentable` until the user opens the Transporter popup and revokes it there — removing the profile does not reach into the browser.

## Verbs

Every verb takes `-p`/`--profile <name>` (except the `profile` subcommands themselves, which take the name as a positional).

| Command | What it does | Example |
|---|---|---|
| `fpx profile add <name> --domain <apex>…` | Create a profile with one or more declared domains. | `fpx profile add opentable --domain opentable.com` |
| `fpx profile declare <name> [--cookie k]… [--local-storage k]… [--session-storage k]… [--capture-header name@host[/path]]… [--dom-selector handle=css]… [--dom-list-selector handle=item-css::field:sel[@attr],…[&max=N]]… [--graphql-op handle=OperationName]… [--allow-download] [--allow-cookie-write] [--allow-in-page] [--allow-capture-redirect]` | Widen a profile's scope. Merges with the existing declaration; forces a re-pair. | `fpx profile declare opentable --cookie oaid` |
| `fpx profile list` | List all profiles and their domains (tab-separated, one per line). | `fpx profile list` |
| `fpx profile show <name>` | Print a profile's full JSON record. | `fpx profile show opentable` |
| `fpx profile remove <name>` | Delete the profile and its identity file. | `fpx profile remove opentable` |
| `fpx pair -p <name> [--domain <apex>] [--subdomain <label>]` | Force a pairing round-trip against one declared domain (defaults to the only one, if there's exactly one). `--subdomain` aims at the host your signed-in tab is actually on — the fetch tab-matcher is strict-prefix, so an apex-only profile won't match a `www.` tab. | `fpx pair -p opentable --subdomain www` |
| `fpx health -p <name>` | Print the bridge's health snapshot (host/peer role, connection state). | `fpx health -p opentable` |
| `fpx get <url> -p <name> [--json] [-H 'K: V']…` | GET a URL through the profile's tab. | `fpx get https://www.opentable.com/ -p opentable` |
| `fpx post-json <url> <body\|@file> -p <name> [--json] [-H 'K: V']…` | POST a JSON body (literal or `@file`); sets `Content-Type: application/json` unless overridden. | `fpx post-json https://www.opentable.com/dapi/fe/gql '{"operationName":"Autocomplete"}' -p opentable` |
| `fpx request <url> -p <name> [-X METHOD] [-H 'K: V']… [-d body\|@file] [--json]` | Arbitrary method/headers/body. | `fpx request https://www.opentable.com/x -p opentable -X DELETE` |
| `fpx cookies [keys…] -p <name>` | Read declared cookies (all declared keys if none given). | `fpx cookies -p opentable` |
| `fpx local-storage [keys…] -p <name>` | Read declared `localStorage` keys. | `fpx local-storage authToken -p opentable` |
| `fpx session-storage [keys…] -p <name>` | Read declared `sessionStorage` keys. | `fpx session-storage csrfToken -p opentable` |
| `fpx indexeddb -p <name>` | Read all declared IndexedDB scopes (no key arguments — scopes come from the profile). | `fpx indexeddb -p opentable` |
| `fpx session -p <name> [--storage-domain d] [--storage-subdomain s]` | Bootstrap-parity one-shot: pair (if needed), read every declared bucket, close, print the combined session JSON. | `fpx session -p opentable` |
| `fpx dom <name…> -p <name> [--storage-domain d] [--storage-subdomain s]` | Read declared DOM selector values (all declared selectors if no names given). Requires `--dom-selector` declarations. | `fpx dom title -p opentable` |
| `fpx dom-list <name> -p <name> [--storage-domain d] [--storage-subdomain s]` | Read a declared REPEATED DOM structure — `querySelectorAll(itemSelector)` plus a per-item field map, one row per matched element. Requires a `--dom-list-selector` declaration for `<name>`. | `fpx dom-list chatMessages -p teams` |
| `fpx download <url> -p <name> [--filename f]` | Download a URL through the browser's own network stack (`chrome.downloads` — real cookies + TLS fingerprint); prints `{path, bytes, mime?, finalUrl?}` for the saved local file. Requires `--allow-download`. | `fpx download https://www.opentable.com/f.pdf -p opentable` |
| `fpx capture [header@host[/path]…] -p <name> [--capture-timeout <s>]` | Snapshot declared request headers off the next matching request the PAGE makes (all declared captures if none named). Requires `--capture-header`. | `fpx capture authorization@api.resy.com -p resy` |
| `fpx capture-redirect <host>[/path] -p <name> [--capture-timeout <s>]` | Report where the next matching request gets redirected to — the target a page-level fetch only ever sees as opaque. Requires `--allow-capture-redirect`. | `fpx capture-redirect api.resy.com/download -p resy` |
| `fpx graphql <handle> -p <name> [--var k=v]… [--via-tab <url>]` | Run a declared GraphQL operation by handle, with typed variables. Requires `--graphql-op`. | `fpx graphql autocomplete -p opentable --var term=sushi` |
| `fpx write-cookies <name=value\|name=@file…>` or `fpx write-cookies --from-stdin`, `-p <name> [--storage-domain d] [--storage-subdomain s]` | Write cookies into the profile's tab and print the names that landed. Requires `--allow-cookie-write`. The value can come off disk or off stdin instead of argv — see below. | `fpx write-cookies sid=@sid.txt -p opentable` |
| `fpx --version` (or `-v`) | Print the CLI version to stdout. The version also appears in the `fpx` / `fpx --help` header. | `fpx --version` |

`--storage-domain` / `--storage-subdomain` (on the storage-read verbs, `session`, and `dom`) select which declared domain to read from when a profile declares more than one — required only in that case, same as the underlying library.

`--dom-selector <handle>=<css>` (on `profile declare`) declares a named DOM read: `<handle>` is the logical name `fpx dom` references, `<css>` is the `document.querySelector` CSS selector the extension reads (first match only, no page-JS execution). `--allow-download` grants the profile the `download` capability, letting `fpx download` save a declared-domain URL through the browser's own network stack.

`--dom-list-selector <handle>=<item-css>::<field>:<selector>[@<attr>][,<field2>:...][&max=N]` (on `profile declare`) declares a named REPEATED DOM read: `<handle>` is the logical name `fpx dom-list` references, `<item-css>` is the `document.querySelectorAll` CSS selector matching each repeated item (may itself contain commas — everything before the first `::` is the item selector), and each `<field>:<selector>[@<attr>]` after `::` (comma-separated) names one output key resolved against each matched item (`<selector>` may be omitted — `field:` alone — to read the item element itself; `@<attr>` reads an attribute instead of `.value`/`.textContent`). `&max=N` (1-1000) caps the row count. This CLI syntax is a convenience for ad-hoc use — it cannot express a `,` or `@` inside a single field's own selector; an MCP declaring `domListSelectors` in code has no such limit.

`--capture-timeout <s>` (on `capture` and `capture-redirect`) is how long to hold the window open, in SECONDS. The server waits out that window and then a little longer for the reply to reach it, so asking for more than 30 seconds now gets you what you asked for. It used to raise the transport deadline for the whole run to achieve that, which lengthened every other verb on the same invocation; the library derives it per verb instead.

### Keeping a cookie value off the command line

`fpx write-cookies` moves a live session cookie, and a value typed as an argument lands in shell history, in `ps` output, and in `/proc/<pid>/cmdline` — where every other process on the machine can read it. Two forms keep it off argv:

```sh
fpx write-cookies sid=@./sid.txt -p opentable          # the value is the file's contents
printf 'sid=%s\n' "$SID" | fpx write-cookies --from-stdin -p opentable
```

- **`name=@file`** reads the value from that file. **Exactly one trailing line ending is stripped** — a `\n`, or the `\r\n` of a CRLF — and nothing else. That is deliberate: `echo "$v" > f` is how the value usually gets into the file, and a cookie carrying a stray newline fails in a way that is hard to see (the write is accepted, the site rejects the session, and nothing in between prints the character). Nothing is trimmed from either end or from inside, so a value with spaces, tabs or `=` in it survives intact; a value that genuinely ends in a newline keeps it by ending the file with two.
- **`--from-stdin`** reads the whole set from stdin, one `name=value` per line. Blank lines are skipped and a trailing `\r` is dropped, so a CRLF stream writes the same cookies a LF one does. Values here are **literal**: there is no `@file` indirection, which is also the way to write a value that genuinely starts with `@`. A line that is not a pair is reported by line NUMBER and never quoted, because such a line is usually a value pasted without its name. A terminal is refused rather than read (nothing closes it), so pipe or redirect into it. The read WAITS for its producer, so a slow one is fine — `op read op://vault/site/cookie | fpx write-cookies --from-stdin -p opentable` reads what the secret manager eventually prints rather than giving up on the empty pipe it finds first.

Pairs on the command line and `--from-stdin` are two sources for one set, and naming both is refused rather than resolved by an invisible precedence rule. Neither form writes anything to disk: the value goes over the bridge and nowhere else.

The same exposure exists for `-H 'Authorization: …'` on the fetch verbs, which has no file form yet.

The four `--allow-*` flags and `--graphql-op` each grant one capability, and each is what its verb needs: `--allow-cookie-write` for `write-cookies`, `--allow-in-page` for `--in-page` on the fetch verbs, `--allow-capture-redirect` for `capture-redirect`, and a `--graphql-op handle=OperationName` declaration per operation `fpx graphql` may run. A capability the profile has not declared is refused before the bridge is dialled, not after.

## Output contract

- **stdout carries data only** — response bodies, `--json` envelopes, storage-read JSON, `profile list`/`profile show` output.
- **Everything else goes to stderr** — pair codes, status lines (`profile "x" created …`, `paired ✓ …`), hints, and error messages.

This split means `fpx get … | jq .` or `fpx cookies -p x > cookies.json` never picks up incidental noise.

### Exit codes

| Code | Meaning |
|---|---|
| `0` | Success. |
| `1` | Usage error — bad flags, unknown command, unknown profile, undeclared scope, malformed body. |
| `2` | The bridge could not deliver an answer — extension not connected, pairing still pending, an unexpected bridge-level failure, **or a healthy round trip that matched nothing** (see below). |
| `3` | Bot wall detected in the response (Akamai/Cloudflare-style interstitial). |
| `4` | Upstream HTTP error — the request reached the site but got a non-2xx response. |

Exit codes 3 and 4 are emitted only by the fetch verbs (`get`, `post-json`, `request`); the `pair`, `health`, `session`, read verbs (`cookies`/`local-storage`/`session-storage`/`indexeddb`), `dom`, and `download` all return 0 on a successful bridge round-trip regardless of upstream status.

`capture` and `capture-redirect` are the exception to "2 means unavailable": a window that closes with nothing matched is a **successful** round trip that found no answer, and it exits 2. That is the ordinary outcome against an idle tab, because the wait resolves on the next matching request the PAGE makes — so an exit 2 from these two verbs is far more often "nothing happened in the tab" than "the bridge is broken". The stderr line says which, and it never advises an update for this case.

## `FETCHPROXY_CLI_HOME`

Profiles are stored at `$FETCHPROXY_CLI_HOME/profiles.json` if that environment variable is set, otherwise at `~/.fetchproxy/cli/profiles.json`. Set it to isolate `fpx` state per-shell, per-CI-job, or for a smoke test:

```sh
FETCHPROXY_CLI_HOME=$(mktemp -d) fpx profile add scratch --domain example.com
```

Per-service identity files are unaffected by this variable — they always live at `~/.fetchproxy/identity/fpx-<name>.json`.

## Capability parity

Every verb — `get`, `post-json`, `request`, `cookies`, `local-storage`, `session-storage`, `indexeddb`, `dom`, `dom-list`, `download`, even `health` and `pair` — derives its hello frame from the *full* profile record via the same `serverOptsFor()` helper, not just the fields that particular verb happens to touch. That's deliberate: the extension's trust is keyed to `(identity, domains, capabilities)`, so if `fpx get` sent a narrower hello than `fpx cookies`, alternating between the two verbs on the same profile would look like a capability change and force a re-pair every time. Sending the same hello for every verb means you can freely interleave `fpx get`, `fpx cookies`, `fpx dom`, `fpx dom-list`, etc. against one profile and only ever pair once — a re-pair only happens when `fpx profile declare` actually changes the stored scope.

One exception: **`fpx session` goes through `@fetchproxy/bootstrap` directly**, not `serverOptsFor()`, and bootstrap has no concept of `domSelectors`/`domListSelectors`/`download` — those fields only exist for `fpx`'s own `dom`/`dom-list`/`download` verbs. So a profile that declares `--dom-selector`/`--dom-list-selector`/`--allow-download` sends a wider hello on the direct verbs than `fpx session` does. In practice this costs nothing but a one-time, non-blocking re-pair the first time you alternate between `fpx session` and `fpx dom`/`fpx dom-list`/`fpx download` on such a profile — the same self-healing behavior already accepted for the storage-pointer scopes.

## Skills

`fpx` is designed to be the thing a skill shells out to when it needs one authenticated read, without embedding a bridge client. A typical pattern:

```sh
# One-time setup (pairs interactively):
fpx profile add opentable --domain opentable.com
fpx pair -p opentable

# From then on, any skill script can do:
fpx get -p opentable 'https://www.opentable.com/dapi/fe/gql?...' --json | jq .body
```

Or, scoped to a declared cookie for a lightweight auth check:

```sh
fpx cookies oaid -p opentable | jq -r .oaid
```

Because pairing is per-profile and persists across invocations, a skill can call `fpx` repeatedly across a session — once per tool call, even — without re-triggering the pair prompt after the first approval.
