# Uploads CLI

> Reference for the uploads CLI and its stdio/hosted MCP tools — exact flags, keys, and contracts for put and attach, screenshot capture, stable PR/issue keys, the managed attachments comment, metadata and search, galleries, config defaults, login/doctor, and output formats. Use when driving the `uploads` CLI or its MCP tools (including the hosted MCP at agents.uploads.sh for agents without local filesystem/git access), when you need a public URL for a local file ("upload this", "host this image", "give me a public URL for this file"), when the CLI itself prints a hint or nudge you need to act on (a `hint` field in `--format json`, or the stderr note suggesting `--pr`/`attach --branch`), or when you need exact flags, key layouts, or setup and auth details. For the when-and-how of getting a screenshot or recording into a GitHub PR or issue, start with the github-screenshots skill — it defers here for CLI detail.

- Skill: `buildinternet/uploads-cli` (Agent Skill)
- Install (CLI): `npx skillmds@latest add buildinternet/uploads-cli`
- Raw SKILL.md: https://api.skillmd.com/api/skills/buildinternet/uploads-cli/raw
- Safety review: pending
- Works with: Claude Code, Claude.ai, OpenAI Codex
- Category: AI & ML
- Author: buildinternet (https://skillmd.com/u/buildinternet)
- Updated: 2026-09-17
- Page: https://skillmd.com/skills/buildinternet/uploads-cli

---


# Uploading files to uploads.sh and embedding in GitHub

## What this does and why

GitHub's native image hosting (`github.com/user-attachments/…`) is reachable from
a **browser session** and, since GitHub CLI 2.99 (September 2026), from
`gh … --attach` on an existing PR or issue with push access. There is still no public
REST endpoint, and the hosted file is private to GitHub. Any other image URL you
put in a PR/issue body written with `gh … --body-file` must already point at
something publicly hosted. The github-screenshots skill says when `gh --attach`
alone is enough.

This skill covers both transports: the **`uploads` CLI** (local files, git,
localhost) and the hosted MCP at `https://agents.uploads.sh/mcp` (bytes you
already have, or a public HTTPS URL to fetch, no checkout). Both PUT to the
uploads.sh API and return a stable public URL plus ready-to-paste markdown.
For PRs and issues the managed attachments comment is available on both.
The CLI can fall back to local `gh`. Hosted MCP is bot-only.

### MCP vs CLI

Same product, two transports. Skills do not install a binary.

| Need                                                  | Use                                             | Why                                                                                                                                                                                                                                                                                                                    |
| ----------------------------------------------------- | ----------------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| Bytes already in context (ChatGPT attachment, base64) | Hosted MCP `put`                                | `files: [{ filename, contentBase64 }]`. Pass `repo` + (`pr` \| `branch`). No git inference.                                                                                                                                                                                                                            |
| File already at a public HTTPS URL                    | CLI `put --url` or hosted MCP `put`             | CLI: `uploads put --url https://… --pr 123`. Hosted: `{ contentUrl }` (filename optional when the URL path has a leaf). Worker/CLI fetches; no auth headers. Hosted rejects private/internal hosts. CLI (and stdio MCP) also fetch `http://localhost` / `127.0.0.1` / `*.localhost`. LAN and link-local stay rejected. |
| List, find, metadata, comment, promote                | Either                                          | Hosted: `list`, `find_files`, `get_metadata` / `set_metadata`, `comment`, `promote`. CLI: `uploads list` / `find` / `meta` / `comment` / `attach --promote`.                                                                                                                                                           |
| Local path or current-branch attach                   | CLI                                             | Hosted server has no filesystem and no `attach` tool. Use `put` instead.                                                                                                                                                                                                                                               |
| `localhost` / private-network screenshot              | CLI `uploads screenshot --via local`            | Remote render cannot reach your machine.                                                                                                                                                                                                                                                                               |
| Selector annotate on a live page                      | CLI `uploads screenshot --annotate --via local` | Remote backend rejects selector-bearing specs.                                                                                                                                                                                                                                                                         |
| Neither transport                                     | Stop                                            | Do not treat `npm install -g` as the ChatGPT path. OAuth on `https://agents.uploads.sh/mcp` is the published remote path.                                                                                                                                                                                              |

CLI examples in the rest of this skill assume a checkout and the `uploads`
binary. Hosted tool contracts live under **Notes and cautions** (the MCP
bullet) below.

For the common case, use `uploads attach <file...>`. It infers the current branch's
PR, uploads every file under stable attachment keys (in parallel), and maintains
the comment by default. One bad file does not block the rest — JSON includes
`uploads` and `failures` (exit `1` when any failed):

```bash
uploads attach ./before.png ./after.png
uploads attach ./shot.png --issue 45 --repo buildinternet/uploads
```

Pass `--no-comment` when only stable URLs are wanted. Use `put` for lower-level
naming and output control.

**Attach an already-uploaded object (issue #702).** An `attach` argument that
doesn't exist on disk but resolves as a workspace object key (e.g.
`f/AbC123/shot.webp`) or an uploads.sh URL (storage host, embed host, or
`/f/` page) attaches via a server-side copy instead of erroring
`file not found` — no re-download/re-upload round trip. The source's own
derived metadata (`path`/`url`/`viewport`/`state`/…) rides along; `gh.repo`/
`gh.kind`/`gh.number`/`gh.ref` are stamped fresh. Copy by default; `--move`
deletes the source after a successful copy. A path that exists on disk always
wins as a local file, even if it would also parse as a key.

```bash
uploads attach f/AbC123/shot.webp --pr 123
uploads attach https://storage.uploads.sh/<workspace>/f/AbC123/shot.webp --pr 123 --move
```

**Stage as you go, before a PR exists.** `uploads attach ./shot.png --branch
[name]` stages files under `gh/<owner>/<repo>/branch/<branch>/<filename>`
instead of a PR/issue number — same upload path, no target flags, no comment
(there's nothing to comment on yet). With no value, `--branch` resolves the
current git branch; `/` in the name sanitizes to `-`. Attach this way at every
visual milestone during the work, not just once at the end. Staged files carry
`gh.status=staged` until promotion flips them to `promoted`, so
`uploads find gh.status=staged` (add `gh.branch=<name>` to narrow) lists what's
still in flight. The server also stamps `gh.uploader`/`gh.uploader-id` (from
the token's minting user) on gh.\*-tagged uploads, so
`uploads find gh.status=staged gh.uploader=<login>` narrows to one
contributor's in-flight files.

**Check what's staged: `uploads staged`.** A dedicated read-only view —
"what's staged for this branch, and will it auto-attach?" — instead of
hand-building the `find`/`list` query above:

```bash
uploads staged                                  # current branch, repo from gh/git remote
uploads staged --branch feature/thing --repo owner/name
uploads staged --format json
```

Same branch/repo resolution as `attach --branch` (current git branch by
default, worktree-safe). Human mode prints one compact line per staged file
(filename, size, `gh.staged-at`, public URL), then a `binding:` line and
`once the PR exists: uploads attach --promote` (the promote line is omitted
for `binding: other` — promoting from a non-owning workspace would be
rejected by the cross-tenant gate). Nothing staged prints a
single zero-state line. `--format json` (or global `--json`) always emits a
valid document — `{ repo, branch, files, binding }` — even with zero files;
`files` is `[]`, never empty stdout.

`binding` folds in the same repo↔workspace check the stage-time warning uses
(see "Repo binding" below), so you don't have to separately reason about it:

| `binding.state` | `binding.autoAttach` | Meaning                                                                                                      |
| --------------- | -------------------- | ------------------------------------------------------------------------------------------------------------ |
| `self`          | `true`               | Repo is bound to this workspace — staged files auto-attach on PR open.                                       |
| `none`          | `false`              | Repo isn't linked yet — link it (`uploads github link`) or nothing auto-attaches.                            |
| `other`         | `false`              | Repo is linked to a **different** workspace — these files won't auto-attach from here.                       |
| `unknown`       | `false`              | Binding check failed (offline, or an older server without the route) — advisory only, never blocks the view. |

The `none`/`other` wording is the exact same advisory text as the
`attach --branch` stage-time warning (issue #398) — one source of truth, so
the two surfaces never drift.

Local stdio MCP mirrors this as the `staged` tool (`branch`/`repo` args,
same `{ repo, branch, files, binding }` shape). The hosted MCP has no
dedicated `staged` tool (no git defaults) — list/find_files recipes and
hosted `put`/`promote` with explicit `repo`/`branch` are under **Notes and
cautions** (the MCP bullet) below.

Getting those files into the PR's attachments comment needs no extra step
once a PR exists for that branch:

- **GitHub App installed** on the repo: a webhook auto-promotes staged files
  into the PR's attachment prefix and creates/updates the managed comment the
  moment the PR opens, reopens, or gets a new commit.
- **No GitHub App**: the next `uploads attach` targeting that PR
  **auto-promotes** those staged files into the PR's attachment prefix before
  the comment refresh. If that first attach has nothing new to upload, run
  `uploads attach --promote` (zero file arguments) to promote and refresh the
  comment on its own; it exits `0` even when nothing was staged. Skip
  auto-promotion on a given call with `--no-promote`.

Promotion only applies to PRs, never issues, and both paths degrade silently
(no error) if the workspace's server doesn't support promotion yet.

**Branch renamed before the PR existed?** Staged keys embed the branch name at
stage time, so promotion under the new head ref would otherwise find nothing.
This is followed automatically: any `uploads` staging or promote command run
on the renamed branch (`attach --branch`, `put`, `attach --pr`, `attach
--promote`) reads the rename from the branch's git reflog, registers it with
the server, and promotion sweeps the old name too. That needs one more
`uploads` run on the branch before the PR opens. If the branch was renamed or
deleted and the PR opened without one, the files sit staged (URLs still work)
but never attach — recover with
`uploads attach --pr <n> --from-branch <old-branch-name>` (zero file arguments
is fine): it promotes that stale branch prefix into the PR and refreshes the
comment. Works for both plain and private-repo staging.

**Promotion needs the repo already bound to the workspace.** Both the webhook
and the CLI-triggered path above rely on the same repo↔workspace binding used
by the managed comment (see "Repo binding" below) — any earlier successful
`attach`/`comment`/promote call against that repo binds it implicitly, or
`uploads github link` claims it explicitly. A repo that has **never** been
bound and is only ever staged with `--branch` sees no error and no comment —
promotion is a silent no-op at PR-open time. If you can't confirm the repo is
already bound, don't promise auto-attach; the zero-setup fallback that works
regardless of binding history is running `uploads attach --promote` (or any
targeted `uploads attach`) once the PR exists.

**Comment missing?** First, give it a moment — if the App is installed and
subscribed to `issue_comment`, a deleted or mangled bot comment self-heals on
the next webhook delivery; don't panic-repost. If it's still missing, check
the repo↔workspace binding — `uploads github link --status` (read-only, shows
the binding without claiming it). See "Repo binding" below.

The killer feature for GitHub: `--pr`/`--issue` produce **hash-free, stable keys**
(`gh/<owner>/<repo>/pull/<num>/<name>`), so re-uploading the same filename overwrites
in place and the URL never changes. There is **no confirmation prompt** — hot-swap is
intentional for agents and re-runs. Human mode prints
`>> replaced existing object (same URL)` after overwrite; JSON has
`"replaced": true|false`. Use `--dry-run` to preview: it prints
`>> would replace existing object (same URL)` when the key already exists,
without writing.

**Every other key is strict** (issue #174): an explicit `--key`, or the
default `put` path with no `--pr`/`--issue`, refuses to overwrite an existing
object — the CLI error names the existing object's URL and tells you to add
`--replace` (MCP: `replace: true`). Set `UPLOADS_OVERWRITE=1` to restore
always-overwrite for those paths. `--dry-run` previews the refusal too:
`>> would refuse: key already exists`. This never applies to `--pr`/`--issue`
keys, which always hot-swap regardless.

Responses include **two** public URLs when the
shared dual-host setup applies:

| Field      | Host (default)       | Use for                                              |
| ---------- | -------------------- | ---------------------------------------------------- |
| `url`      | `storage.uploads.sh` | Durable link, click-through, non-GitHub embeds       |
| `embedUrl` | `embed.uploads.sh`   | **GitHub PR/issue markdown** (`<img src>` / `![]()`) |

`embedUrl` is the same object with badge-style no-cache headers so GitHub Camo
revalidates after an overwrite. CLI/MCP `markdown` and the managed attachments
comment already prefer `embedUrl`. Override with `UPLOADS_EMBED_PUBLIC_BASE_URL`
(empty disables; self-host set your no-cache CDN base).

## Prerequisites

- **No shell / ChatGPT?** Skip this section. Use the hosted MCP
  (`https://agents.uploads.sh/mcp`) and the table above. Do not install the CLI.
- **Node.js ≥ 22.**
- **The CLI.** Install globally for repeated agent use, or run it once with `npx`:
  ```bash
  npm install --global @buildinternet/uploads
  npx @buildinternet/uploads --help
  uploads --version
  ```
  Every example in this skill uses the **global** `uploads …` binary (as after
  install). Inside the uploads monorepo only, `pnpm uploads …` builds from
  local source first — do not write product/PR examples that way.
  Prefer `--json` or `--quiet` for scripted steps (keeps stderr clean and skips
  optional update-available hints).
- **A configured token** (one-time — see below). Check with `uploads doctor`.
- **`gh` CLI, authenticated** — only for the `--comment` / `comment` features that
  write to a PR/issue. Plain uploads don't need it.

## One-time setup

Config lives in a user-owned file so it survives skill reinstalls:

```
~/.config/buildinternet/config        # or $XDG_CONFIG_HOME/buildinternet/config
```

Resolution is **per key, first match wins**: CLI flags (`--api-url`, `--token`,
`--workspace`) → `UPLOADS_*` environment vars → `--env-file <path>` →
`$BUILDINTERNET_CONFIG` → the shared config file. For a one-off against a different
API or workspace, just export the var or pass `--env-file`.

The fastest path is `uploads login`. Have a workspace admin invite your
email to a workspace first, then run it once, interactively, to sign in:

```bash
uploads login          # opens a browser to approve sign-in, saves config, runs doctor
uploads login --workspace acme   # only needed if your account can access more than one
```

If the account has no workspace yet, `login` prompts for a name and offers one
derived from your GitHub login as a bracketed default — press Enter to take it,
or type your own. Nothing is prefilled when no valid, unclaimed name can be
derived. `--workspace <name> --create` skips the prompt entirely, which is the
form to use in scripts.

That's a one-time, human-in-the-loop step (device sign-in needs a browser); once the
config file is written, every later `uploads` invocation — including from a
non-interactive agent — just reads the saved token. Routine agents never need
`ADMIN_TOKEN`.

**Inviting a teammate** (workspace admin/owner only): open the people tab under
`/account/workspaces/<name>/people` in the browser (invite, revoke pending
invites, promote members to admin), or:

```bash
uploads invite create --email teammate@example.com --workspace acme
```

Device login as you (not `ADMIN_TOKEN` / not a workspace token). The CLI prints an
accept URL to share if email isn’t configured. Invitee accepts, then `uploads login`.
Workspace admins can promote existing members to admin on that people tab; only the
workspace owner can demote or remove other admins.

For headless machines with no browser at all, an operator can mint a token directly
(`/admin/tokens`, `ADMIN_TOKEN`-gated — see `docs/admin-tokens.md`) and hand it to the
agent as `UPLOADS_TOKEN`, or an enrollment code (`upe_…`, an alternative invite-link/code path — useful
when you don't have the recipient's email) can be exchanged with `uploads login --code`.
Neither is the normal path for new setups.

The resulting token defaults to 90 days and `files:read` plus `files:write`; it cannot
delete files unless an administrator explicitly grants `files:delete`. Verify or inspect
setup at any time:

```bash
uploads setup                                  # shows effective configuration
uploads doctor                                 # version + health + auth + workspace
uploads doctor --json
```

Workspace tokens encode their workspace (`up_<workspace>_…`), so the CLI infers
`--workspace` when you don't set it. `/account/developers` mints the same
token shape and can skip expiry (revoke is then the only off switch). Legacy
administrator-minted tokens remain valid.
See "Config commands" for setting put defaults (default repo, prefix, image
width) once instead of per-command.

## Core workflow: `uploads put`

Upload one or more files and get back URL(s) plus ready-to-paste markdown.
Multiple paths upload in parallel; multi-file JSON is `{ uploads, failures }`
(exit `1` when any failed). Single-file JSON stays a flat object.

```bash
uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt "New live feed cards" --width 700
uploads put ./before.png ./after.png
uploads put --url https://cdn.example/shot.png --pr 123 --name hero.png
```

Human output goes to stderr; the URL and markdown to stdout, so you can pipe or
capture them. Use `-` as the file to read from stdin.

Key options (`uploads put --help` for all):

| Flag                                  | Purpose                                                                                                                                                                                                                                                                      |
| ------------------------------------- | ---------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| `--alt <text>`                        | Alt text for the markdown (default: filename). Always write meaningful alt text.                                                                                                                                                                                             |
| `--width <px>`                        | Emit sized `<img width=…>` HTML instead of `![]()` (markdown can't size images).                                                                                                                                                                                             |
| `--repo <owner/repo>`                 | Repo segment of the auto key (default: git remote, or `UPLOADS_DEFAULT_REPO`).                                                                                                                                                                                               |
| `--ref <id>`                          | PR/issue/branch/date segment (default: today, or `UPLOADS_DEFAULT_REF`).                                                                                                                                                                                                     |
| `--destination <id>`                  | Typed root: `screenshots` \| `gh` \| `f` (sets key prefix).                                                                                                                                                                                                                  |
| `--prefix <path>`                     | Key prefix (default: `screenshots`, or `UPLOADS_DEFAULT_PREFIX`).                                                                                                                                                                                                            |
| `--key <key>`                         | Set the object key explicitly; skips the auto-naming below.                                                                                                                                                                                                                  |
| `--name <leaf>`                       | Clean filename for the key's leaf + default alt (no `/`); keeps the `--pr`/default path. Not with `--key`.                                                                                                                                                                   |
| `--url <url>`                         | Fetch this URL and upload its body (repeatable). Not with file arguments. Public HTTPS, or `http://localhost` / `127.0.0.1` / `*.localhost` on this machine. Filename comes from the URL path, or `--name`. Other private/internal hosts are rejected; no auth is forwarded. |
| `--replace`                           | Allow overwriting an existing object on a strict key (`--key`/default path). No effect on `--pr`/`--issue` (or `UPLOADS_OVERWRITE=1`).                                                                                                                                       |
| `--dry-run`                           | Resolve + print the key and final public URL without uploading; reports if the key would replace (or, on a strict key, be refused). Not with `--gallery`; skips the managed comment sync even with `--pr`/`--issue`.                                                         |
| `--content-type <mime>`               | Override the content type (else inferred from extension; ignored when optimize rewrites the body).                                                                                                                                                                           |
| `--frame <id>`                        | Opt-in chrome before optimize: `phone`, `browser`, `iphone-16-pro`.                                                                                                                                                                                                          |
| `--frame-url <url>`                   | Address bar text for `--frame browser`.                                                                                                                                                                                                                                      |
| `--frame-fit cover\|contain`          | How the shot fills the screen (default: `cover`).                                                                                                                                                                                                                            |
| `--no-optimize`                       | Skip client-side image optimization (default: still images → WebP). Or `UPLOADS_NO_OPTIMIZE=1`.                                                                                                                                                                              |
| `--optimize-max-edge <px>`            | Max long edge when optimizing (default: 2400).                                                                                                                                                                                                                               |
| `--optimize-quality <1-100>`          | WebP quality when optimizing (default: 85).                                                                                                                                                                                                                                  |
| `--keep-exif`                         | Keep EXIF/XMP/ICC when optimizing (default: **strip** for privacy). Or `UPLOADS_KEEP_EXIF=1`.                                                                                                                                                                                |
| `--no-git`                            | Don't derive `--repo` from the git remote (or `UPLOADS_NO_GIT=1`).                                                                                                                                                                                                           |
| `--format human\|url\|markdown\|json` | Control stdout. `--json` (global) forces json.                                                                                                                                                                                                                               |
| `-w, --workspace <name>`              | Override workspace (wins over env and token inference).                                                                                                                                                                                                                      |

**Image optimization (default on):** PNG/JPEG and similar still images are re-encoded to
WebP (long edge capped at 2400px, quality 85) before upload so PR/issue embeds stay
lean. The object key/filename extension follows the output (e.g. `shot.png` →
`…/shot.webp`). **EXIF/XMP is stripped by default** (public URLs + privacy); pass
`--keep-exif` when the discussion needs the embedded image metadata. Animated GIF,
SVG, video, and non-images are left alone; if the optimized payload is not smaller,
the original is uploaded. Use `--no-optimize` when you need lossless originals.

**Frames (opt-in):** `--frame phone` (generic bezel), `--frame browser`, or
`--frame iphone-16-pro` (community device art, cached under
`~/.cache/uploads/frames`). Default is **no frame**.

**How keys work** — three paths, no extra naming modes:

| Intent                                | Command                                                        |
| ------------------------------------- | -------------------------------------------------------------- |
| Just upload it, give me a URL         | `uploads put ./file.png`                                       |
| Already hosted at a public HTTPS URL  | `uploads put --url https://cdn.example/file.png`               |
| Explicit typed destination            | `uploads put ./file.png --destination screenshots`             |
| Stable GitHub embed I might re-upload | `uploads put ./file.png --pr <num>`                            |
| Stable `--pr` path but a clean leaf   | `uploads put ./capture-2026-…Z.png --pr <num> --name hero.png` |
| I know exactly where it goes          | `uploads put ./file.png --key screenshots/…/x.png`             |

Timestamped captures break stable `--pr` keys — pass `--name hero.webp` to keep a
clean leaf. Use `--dry-run` to preview the exact public URL before uploading.

Default `put` is the fast path; you don't need `--key`, `--prefix`, or `--repo`.
**Inside a git repo, on a non-default branch, a bare `put` now stages
automatically** — same key/metadata as `attach --branch`
(`gh/<owner>/<repo>/branch/<branch>/<filename>`), so it auto-attaches to that
branch's PR when one opens. This fires whenever none of
`--pr`/`--issue`/`--key`/`--ref`/`--prefix`/`--destination` is set and
`--no-git` isn't passed; any of those flags (or the default branch, detached
HEAD, not being in a git repo, or `--no-git`) falls back to the classic
**dated** layout:
`<prefix>/<repo-name>/<ref-or-date>/<basename>-<shorthash>.<ext>` — the short
hash prevents collisions without random names or a separate "preserve name"
flag. Prefer `--destination screenshots` (or `gh` with `--pr`/`--issue`) over
inventing roots — workspaces may allowlist only those destinations. Override
with `--key` only when you have a reason, and keep the key under an allowed
root. Pass `--ref`/`--prefix`/`--destination` explicitly for a plain dated
upload on a branch (the opt-out).

**Output formats** — pick what you'll consume:

```bash
uploads put ./shot.png --format url        # just the URL, for scripting
uploads put ./shot.png --format markdown   # just the ![]()/<img> snippet
uploads put ./shot.png --json              # {workspace,key,url,size,markdown}
```

**The bare-put staging note.** Since a bare `put` on a
non-default branch now stages by default (see above), it prints a one-line
note confirming that instead of nudging you to do it yourself — human mode
writes it to stderr, `--format json` adds it as an additive optional `hint`
field on the same response:

```text
note: staged for branch fix-header — auto-comments to pull request when opened
(or run: uploads attach --promote once it exists). Use --ref/--prefix for a
plain dated upload.
```

If the same call also trips the stage-time binding warning (issue #398/#400
— the repo isn't bound to this workspace), that warning takes the `hint`
slot instead (it's the more actionable of the two); both still print on
stderr in human mode. Suppress the note (not the staging itself) with
`--quiet`, `UPLOADS_NO_NUDGE=1` (env), or `UPLOADS_NO_NUDGE=1` in the config
file (`uploads config set UPLOADS_NO_NUDGE 1`).

**The old "rerun with --pr" nudge (issue #393)** still fires, unchanged, for
the narrower case a bare put still lands on the dated layout with a
detectable PR — in practice, an explicit `--ref`/`--prefix` opting out of
staging while a PR is open for that branch:

```text
note: on branch fix-header (PR #142 open) — rerun with --pr 142 for a stable
key plus a managed comment that collects this PR's media, or stage pre-PR
files with: uploads attach <file> --branch
```

It's best-effort (a quick `gh pr view` lookup, bounded to 3s) — no open PR
just widens the wording to a generic `--pr <num>`. Same suppression as above.

## Capturing a screenshot: `uploads screenshot`

Capture a URL or a local `.html` file and host it — no separate screenshot
tool needed, and no browser install required for the default path:

```bash
uploads screenshot https://uploads.sh --pr 128
uploads screenshot ./card.html --out ./card.png
uploads screenshot ./card.html --no-upload --out ./card.png
```

After capture, a screenshot shares the exact `put` upload pipeline described
above: optional `--frame`, optimize-by-default, `--pr`/`--issue` attachment +
`--comment`, `--gallery`, `--meta`, and the same output formats. It also
ships as an MCP tool (`screenshot`) alongside the CLI command.

**Two capture backends**, selected with `--via`:

| Backend  | What it is                                                        | Needs                                                               |
| -------- | ----------------------------------------------------------------- | ------------------------------------------------------------------- |
| `local`  | Drives an already-installed Chrome/Chromium via `playwright-core` | A discoverable browser on disk, or `--cdp`                          |
| `remote` | Renders server-side via the uploads.sh render endpoint            | Nothing local; counts against the workspace's monthly upload budget |

`--via auto` (the default) prefers local when a usable browser is found,
else falls back to remote. Set a persistent default with
`UPLOADS_SCREENSHOT_VIA=auto|local|remote` (env, `--env-file`, or the user
config file — see "Config commands"); the `--via` flag always wins.

**localhost/private-network targets are local-only.** With `--via remote`
(or `auto` falling back to remote) these fail fast with a clear error instead
of sending a request that could never work. Local `.html` files work on both
backends — the remote backend receives the file's contents inline (≤ 2 MiB),
so anything the page references via `file://` or relative paths only resolves
with `--via local`. A numeric
`--wait <ms>` (fixed settle delay after load) is also local-only; use
`--wait load|domcontentloaded|networkidle` for a backend-agnostic wait.

Use `--cdp <endpoint>` to attach to a Chrome that's already running
(`http://host:port` or `ws://…`) instead of launching a new one — handy when
an agent already has a Playwright MCP or `agent-browser` session open.
`--browser <path>` (or `UPLOADS_CHROME_PATH` / `CHROME_PATH`) points at an
explicit executable.

Key options (`uploads screenshot --help` for all):

| Flag                                                 | Purpose                                                                                                                                                                                                                                                                                                                                                                  |
| ---------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------ |
| `--via auto\|local\|remote`                          | Capture backend (default: `auto`, or `UPLOADS_SCREENSHOT_VIA`).                                                                                                                                                                                                                                                                                                          |
| `--browser <path>`                                   | Explicit local browser executable (or `UPLOADS_CHROME_PATH` / `CHROME_PATH`).                                                                                                                                                                                                                                                                                            |
| `--cdp <endpoint>`                                   | Attach to a running Chrome via CDP instead of launching one (local backend only).                                                                                                                                                                                                                                                                                        |
| `--viewport <WxH[@Sx]>`                              | Size + device scale factor (default: `1280x800@2`).                                                                                                                                                                                                                                                                                                                      |
| `--selector <css>`                                   | Capture one element instead of the viewport.                                                                                                                                                                                                                                                                                                                             |
| `--full-page`                                        | Capture the full scrollable page.                                                                                                                                                                                                                                                                                                                                        |
| `--max-height <px>`                                  | Cap on `--full-page` capture height in CSS px (default: `5000`; `0` = uncapped). A page over the cap is clipped, with a note to stderr and a `--format json` `hint`. Requires `--full-page`; applies on both `--via local` and `--via remote`.                                                                                                                           |
| `--dark` / `--light`                                 | Emulate `prefers-color-scheme` (full media-query emulation on `--via local` only — `--via remote` only sets the CSS `color-scheme` property, so a page's own `prefers-color-scheme` queries won't flip).                                                                                                                                                                 |
| `--wait <load\|domcontentloaded\|networkidle\|ms>`   | Settle strategy (default: `load`); a millisecond count is local-only.                                                                                                                                                                                                                                                                                                    |
| `--wait-for <js>`                                    | Poll this JS expression in the page until truthy before `--eval` and capture (local backend only). Bridges framework hydration — `load`/`networkidle` settle before React/Next attach handlers, so a synthetic click in `--eval` hits the inert SSR DOM. Express the app's own signal, e.g. `--wait-for 'window.__hydrated===true'`. Times out with the capture timeout. |
| `--eval <js>` / `--init-script <file>`               | Run setup JS after settle / inject a script before navigation (local backend only). Synthetic events (`el.click()`) won't reach framework handlers until the app hydrates — pair `--eval` with `--wait-for` on React/Next apps.                                                                                                                                          |
| `--out <file>`                                       | Also write the PNG to a local file, plus a sidecar manifest (`<file>.uploads.json`) with this capture's derived metadata (`path`/`url`/`env`/`viewport`, plus `--state` if given) and a content hash. A later `put`/`attach` of that exact file picks the metadata back up automatically — explicit `--meta`/`--state` still win. See `--no-sidecar`.                    |
| `--no-sidecar`                                       | Don't write the `<file>.uploads.json` sidecar alongside `--out`.                                                                                         

…(truncated)
