{"slug":"uploads-cli","title":"uploads-cli","summary":"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 wh","platform":"Claude","tags":[],"authorName":"LLM Mart","authorSlug":"llm-mart","score":0,"source":"github","price":null,"verified":false,"createdAt":"2026-08-24T16:57:45.048479Z","repo":{"url":"https://github.com/buildinternet/uploads","stars":19,"forks":2,"license":"Apache-2.0","updatedAt":"2026-09-27T17:46:32Z"},"bodyHtml":"<hr>\n<h2>name: uploads-cli\ndescription: &gt;-\nReference for the uploads CLI and its stdio/hosted MCP tools — exact flags,\nkeys, and contracts for put and attach, screenshot capture, stable PR/issue\nkeys, the managed attachments comment, metadata and search, galleries,\nconfig defaults, login/doctor, and output formats. Use when driving the\n<code>uploads</code> CLI or its MCP tools (including the hosted MCP at\nagents.uploads.sh for agents without local filesystem/git access), when you\nneed a public URL for a local file (\"upload this\", \"host this image\", \"give\nme a public URL for this file\"), when the CLI itself prints a hint or nudge\nyou need to act on (a <code>hint</code> field in <code>--format json</code>, or the stderr note\nsuggesting <code>--pr</code>/<code>attach --branch</code>), or when you need exact flags, key\nlayouts, or setup and auth details. For the when-and-how of getting a\nscreenshot or recording into a GitHub PR or issue, start with the\ngithub-screenshots skill — it defers here for CLI detail.</h2>\n<h1>Uploading files to uploads.sh and embedding in GitHub</h1>\n<h2>What this does and why</h2>\n<p>GitHub's native image hosting (<code>github.com/user-attachments/…</code>) is only reachable\nthrough an authenticated <strong>browser session</strong> — there is no <code>gh</code> CLI or REST endpoint\nfor it. So any image URL you put in a PR/issue body written with <code>gh … --body-file</code>\nmust already point at something publicly hosted.</p>\n<p>This skill covers both transports: the <strong><code>uploads</code> CLI</strong> (local files, git,\nlocalhost) and the hosted MCP at <code>https://agents.uploads.sh/mcp</code> (bytes you\nalready have, no checkout). Both PUT to the uploads.sh API and return a stable\npublic URL plus ready-to-paste markdown. For PRs and issues the managed\nattachments comment is available on both — CLI via local <code>gh</code> as a fallback,\nhosted MCP bot-only.</p>\n<h3>MCP vs CLI</h3>\n<p>Same product, two transports. Skills do not install a binary.</p>\n<table>\n<thead>\n<tr>\n<th>Need</th>\n<th>Use</th>\n<th>Why</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Bytes already in context (ChatGPT attachment, base64)</td>\n<td>Hosted MCP <code>put</code></td>\n<td><code>files: [{ filename, contentBase64 }]</code>. Pass <code>repo</code> + (<code>pr</code> | <code>branch</code>). No git inference.</td>\n</tr>\n<tr>\n<td>List, find, metadata, comment, promote</td>\n<td>Either</td>\n<td>Hosted: <code>list</code>, <code>find_files</code>, <code>get_metadata</code> / <code>set_metadata</code>, <code>comment</code>, <code>promote</code>. CLI: <code>uploads list</code> / <code>find</code> / <code>meta</code> / <code>comment</code> / <code>attach --promote</code>.</td>\n</tr>\n<tr>\n<td>Local path or current-branch attach</td>\n<td>CLI</td>\n<td>Hosted server has no filesystem and no <code>attach</code> tool. Use <code>put</code> instead.</td>\n</tr>\n<tr>\n<td><code>localhost</code> / private-network screenshot</td>\n<td>CLI <code>uploads screenshot --via local</code></td>\n<td>Remote render cannot reach your machine.</td>\n</tr>\n<tr>\n<td>Selector annotate on a live page</td>\n<td>CLI <code>uploads screenshot --annotate --via local</code></td>\n<td>Remote backend rejects selector-bearing specs.</td>\n</tr>\n<tr>\n<td>Neither transport</td>\n<td>Stop</td>\n<td>Do not treat <code>npm install -g</code> as the ChatGPT path. OAuth on <code>https://agents.uploads.sh/mcp</code> is the published remote path.</td>\n</tr>\n</tbody>\n</table>\n<p>CLI examples in the rest of this skill assume a checkout and the <code>uploads</code>\nbinary. Hosted tool contracts live under <strong>Notes and cautions</strong> (the MCP\nbullet) below.</p>\n<p>For the common case, use <code>uploads attach &lt;file...&gt;</code>. It infers the current branch's\nPR, uploads every file under stable attachment keys (in parallel), and maintains\nthe comment by default. One bad file does not block the rest — JSON includes\n<code>uploads</code> and <code>failures</code> (exit <code>1</code> when any failed):</p>\n<pre><code>uploads attach ./before.png ./after.png\nuploads attach ./shot.png --issue 45 --repo buildinternet/uploads\n</code></pre>\n<p>Pass <code>--no-comment</code> when only stable URLs are wanted. Use <code>put</code> for lower-level\nnaming and output control.</p>\n<p><strong>Attach an already-uploaded object (issue #702).</strong> An <code>attach</code> argument that\ndoesn't exist on disk but resolves as a workspace object key (e.g.\n<code>f/AbC123/shot.webp</code>) or an uploads.sh URL (storage host, embed host, or\n<code>/f/</code> page) attaches via a server-side copy instead of erroring\n<code>file not found</code> — no re-download/re-upload round trip. The source's own\nderived metadata (<code>path</code>/<code>url</code>/<code>viewport</code>/<code>state</code>/…) rides along; <code>gh.repo</code>/\n<code>gh.kind</code>/<code>gh.number</code>/<code>gh.ref</code> are stamped fresh. Copy by default; <code>--move</code>\ndeletes the source after a successful copy. A path that exists on disk always\nwins as a local file, even if it would also parse as a key.</p>\n<pre><code>uploads attach f/AbC123/shot.webp --pr 123\nuploads attach https://storage.uploads.sh/&lt;workspace&gt;/f/AbC123/shot.webp --pr 123 --move\n</code></pre>\n<p><strong>Stage as you go, before a PR exists.</strong> <code>uploads attach ./shot.png --branch [name]</code> stages files under <code>gh/&lt;owner&gt;/&lt;repo&gt;/branch/&lt;branch&gt;/&lt;filename&gt;</code>\ninstead of a PR/issue number — same upload path, no target flags, no comment\n(there's nothing to comment on yet). With no value, <code>--branch</code> resolves the\ncurrent git branch; <code>/</code> in the name sanitizes to <code>-</code>. Attach this way at every\nvisual milestone during the work, not just once at the end. Staged files carry\n<code>gh.status=staged</code> until promotion flips them to <code>promoted</code>, so\n<code>uploads find gh.status=staged</code> (add <code>gh.branch=&lt;name&gt;</code> to narrow) lists what's\nstill in flight. The server also stamps <code>gh.uploader</code>/<code>gh.uploader-id</code> (from\nthe token's minting user) on gh.*-tagged uploads, so\n<code>uploads find gh.status=staged gh.uploader=&lt;login&gt;</code> narrows to one\ncontributor's in-flight files.</p>\n<p><strong>Check what's staged: <code>uploads staged</code>.</strong> A dedicated read-only view —\n\"what's staged for this branch, and will it auto-attach?\" — instead of\nhand-building the <code>find</code>/<code>list</code> query above:</p>\n<pre><code>uploads staged                                  # current branch, repo from gh/git remote\nuploads staged --branch feature/thing --repo owner/name\nuploads staged --format json\n</code></pre>\n<p>Same branch/repo resolution as <code>attach --branch</code> (current git branch by\ndefault, worktree-safe). Human mode prints one compact line per staged file\n(filename, size, <code>gh.staged-at</code>, public URL), then a <code>binding:</code> line and\n<code>once the PR exists: uploads attach --promote</code> (the promote line is omitted\nfor <code>binding: other</code> — promoting from a non-owning workspace would be\nrejected by the cross-tenant gate). Nothing staged prints a\nsingle zero-state line. <code>--format json</code> (or global <code>--json</code>) always emits a\nvalid document — <code>{ repo, branch, files, binding }</code> — even with zero files;\n<code>files</code> is <code>[]</code>, never empty stdout.</p>\n<p><code>binding</code> folds in the same repo↔workspace check the stage-time warning uses\n(see \"Repo binding\" below), so you don't have to separately reason about it:</p>\n<table>\n<thead>\n<tr>\n<th><code>binding.state</code></th>\n<th><code>binding.autoAttach</code></th>\n<th>Meaning</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>self</code></td>\n<td><code>true</code></td>\n<td>Repo is bound to this workspace — staged files auto-attach on PR open.</td>\n</tr>\n<tr>\n<td><code>none</code></td>\n<td><code>false</code></td>\n<td>Repo isn't linked yet — link it (<code>uploads github link</code>) or nothing auto-attaches.</td>\n</tr>\n<tr>\n<td><code>other</code></td>\n<td><code>false</code></td>\n<td>Repo is linked to a <strong>different</strong> workspace — these files won't auto-attach from here.</td>\n</tr>\n<tr>\n<td><code>unknown</code></td>\n<td><code>false</code></td>\n<td>Binding check failed (offline, or an older server without the route) — advisory only, never blocks the view.</td>\n</tr>\n</tbody>\n</table>\n<p>The <code>none</code>/<code>other</code> wording is the exact same advisory text as the\n<code>attach --branch</code> stage-time warning (issue #398) — one source of truth, so\nthe two surfaces never drift.</p>\n<p>Local stdio MCP mirrors this as the <code>staged</code> tool (<code>branch</code>/<code>repo</code> args,\nsame <code>{ repo, branch, files, binding }</code> shape). The hosted MCP has no\ndedicated <code>staged</code> tool (no git defaults) — list/find_files recipes and\nhosted <code>put</code>/<code>promote</code> with explicit <code>repo</code>/<code>branch</code> are under <strong>Notes and\ncautions</strong> (the MCP bullet) below.</p>\n<p>Getting those files into the PR's attachments comment needs no extra step\nonce a PR exists for that branch:</p>\n<ul>\n<li><strong>GitHub App installed</strong> on the repo: a webhook auto-promotes staged files\ninto the PR's attachment prefix and creates/updates the managed comment the\nmoment the PR opens, reopens, or gets a new commit.</li>\n<li><strong>No GitHub App</strong>: the next <code>uploads attach</code> targeting that PR\n<strong>auto-promotes</strong> those staged files into the PR's attachment prefix before\nthe comment refresh. If that first attach has nothing new to upload, run\n<code>uploads attach --promote</code> (zero file arguments) to promote and refresh the\ncomment on its own; it exits <code>0</code> even when nothing was staged. Skip\nauto-promotion on a given call with <code>--no-promote</code>.</li>\n</ul>\n<p>Promotion only applies to PRs, never issues, and both paths degrade silently\n(no error) if the workspace's server doesn't support promotion yet.</p>\n<p><strong>Promotion needs the repo already bound to the workspace.</strong> Both the webhook\nand the CLI-triggered path above rely on the same repo↔workspace binding used\nby the managed comment (see \"Repo binding\" below) — any earlier successful\n<code>attach</code>/<code>comment</code>/promote call against that repo binds it implicitly, or\n<code>uploads github link</code> claims it explicitly. A repo that has <strong>never</strong> been\nbound and is only ever staged with <code>--branch</code> sees no error and no comment —\npromotion is a silent no-op at PR-open time. If you can't confirm the repo is\nalready bound, don't promise auto-attach; the zero-setup fallback that works\nregardless of binding history is running <code>uploads attach --promote</code> (or any\ntargeted <code>uploads attach</code>) once the PR exists.</p>\n<p><strong>Comment missing?</strong> First, give it a moment — if the App is installed and\nsubscribed to <code>issue_comment</code>, a deleted or mangled bot comment self-heals on\nthe next webhook delivery; don't panic-repost. If it's still missing, check\nthe repo↔workspace binding — <code>uploads github link --status</code> (read-only, shows\nthe binding without claiming it). See \"Repo binding\" below.</p>\n<p>The killer feature for GitHub: <code>--pr</code>/<code>--issue</code> produce <strong>hash-free, stable keys</strong>\n(<code>gh/&lt;owner&gt;/&lt;repo&gt;/pull/&lt;num&gt;/&lt;name&gt;</code>), so re-uploading the same filename overwrites\nin place and the URL never changes. There is <strong>no confirmation prompt</strong> — hot-swap is\nintentional for agents and re-runs. Human mode prints\n<code>&gt;&gt; replaced existing object (same URL)</code> after overwrite; JSON has\n<code>\"replaced\": true|false</code>. Use <code>--dry-run</code> to preview: it prints\n<code>&gt;&gt; would replace existing object (same URL)</code> when the key already exists,\nwithout writing.</p>\n<p><strong>Every other key is strict</strong> (issue #174): an explicit <code>--key</code>, or the\ndefault <code>put</code> path with no <code>--pr</code>/<code>--issue</code>, refuses to overwrite an existing\nobject — the CLI error names the existing object's URL and tells you to add\n<code>--replace</code> (MCP: <code>replace: true</code>). Set <code>UPLOADS_OVERWRITE=1</code> to restore\nalways-overwrite for those paths. <code>--dry-run</code> previews the refusal too:\n<code>&gt;&gt; would refuse: key already exists</code>. This never applies to <code>--pr</code>/<code>--issue</code>\nkeys, which always hot-swap regardless.</p>\n<p>Responses include <strong>two</strong> public URLs when the\nshared dual-host setup applies:</p>\n<table>\n<thead>\n<tr>\n<th>Field</th>\n<th>Host (default)</th>\n<th>Use for</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>url</code></td>\n<td><code>storage.uploads.sh</code></td>\n<td>Durable link, click-through, non-GitHub embeds</td>\n</tr>\n<tr>\n<td><code>embedUrl</code></td>\n<td><code>embed.uploads.sh</code></td>\n<td><strong>GitHub PR/issue markdown</strong> (<code>&lt;img src&gt;</code> / <code>![]()</code>)</td>\n</tr>\n</tbody>\n</table>\n<p><code>embedUrl</code> is the same object with badge-style no-cache headers so GitHub Camo\nrevalidates after an overwrite. CLI/MCP <code>markdown</code> and the managed attachments\ncomment already prefer <code>embedUrl</code>. Override with <code>UPLOADS_EMBED_PUBLIC_BASE_URL</code>\n(empty disables; self-host set your no-cache CDN base).</p>\n<h2>Prerequisites</h2>\n<ul>\n<li><strong>No shell / ChatGPT?</strong> Skip this section. Use the hosted MCP\n(<code>https://agents.uploads.sh/mcp</code>) and the table above. Do not install the CLI.</li>\n<li><strong>Node.js ≥ 22.</strong></li>\n<li><strong>The CLI.</strong> Install globally for repeated agent use, or run it once with <code>npx</code>:\n<pre><code>npm install --global @buildinternet/uploads\nnpx @buildinternet/uploads --help\nuploads --version\n</code></pre>\nEvery example in this skill uses the <strong>global</strong> <code>uploads …</code> binary (as after\ninstall). Inside the uploads monorepo only, <code>pnpm uploads …</code> builds from\nlocal source first — do not write product/PR examples that way.\nPrefer <code>--json</code> or <code>--quiet</code> for scripted steps (keeps stderr clean and skips\noptional update-available hints).</li>\n<li><strong>A configured token</strong> (one-time — see below). Check with <code>uploads doctor</code>.</li>\n<li><strong><code>gh</code> CLI, authenticated</strong> — only for the <code>--comment</code> / <code>comment</code> features that\nwrite to a PR/issue. Plain uploads don't need it.</li>\n</ul>\n<h2>One-time setup</h2>\n<p>Config lives in a user-owned file so it survives skill reinstalls:</p>\n<pre><code>~/.config/buildinternet/config        # or $XDG_CONFIG_HOME/buildinternet/config\n</code></pre>\n<p>Resolution is <strong>per key, first match wins</strong>: CLI flags (<code>--api-url</code>, <code>--token</code>,\n<code>--workspace</code>) → <code>UPLOADS_*</code> environment vars → <code>--env-file &lt;path&gt;</code> →\n<code>$BUILDINTERNET_CONFIG</code> → the shared config file. For a one-off against a different\nAPI or workspace, just export the var or pass <code>--env-file</code>.</p>\n<p>The fastest path is <code>uploads login</code>. Have a workspace admin invite your\nemail to a workspace first, then run it once, interactively, to sign in:</p>\n<pre><code>uploads login          # opens a browser to approve sign-in, saves config, runs doctor\nuploads login --workspace acme   # only needed if your account can access more than one\n</code></pre>\n<p>If the account has no workspace yet, <code>login</code> prompts for a name and offers one\nderived from your GitHub login as a bracketed default — press Enter to take it,\nor type your own. Nothing is prefilled when no valid, unclaimed name can be\nderived. <code>--workspace &lt;name&gt; --create</code> skips the prompt entirely, which is the\nform to use in scripts.</p>\n<p>That's a one-time, human-in-the-loop step (device sign-in needs a browser); once the\nconfig file is written, every later <code>uploads</code> invocation — including from a\nnon-interactive agent — just reads the saved token. Routine agents never need\n<code>ADMIN_TOKEN</code>.</p>\n<p><strong>Inviting a teammate</strong> (workspace admin/owner only): open the people tab under\n<code>/account/workspaces/&lt;name&gt;/people</code> in the browser (invite, revoke pending\ninvites, promote members to admin), or:</p>\n<pre><code>uploads invite create --email teammate@example.com --workspace acme\n</code></pre>\n<p>Device login as you (not <code>ADMIN_TOKEN</code> / not a workspace token). The CLI prints an\naccept URL to share if email isn’t configured. Invitee accepts, then <code>uploads login</code>.\nWorkspace admins can promote existing members to admin on that people tab; only the\nworkspace owner can demote or remove other admins.</p>\n<p>For headless machines with no browser at all, an operator can mint a token directly\n(<code>/admin/tokens</code>, <code>ADMIN_TOKEN</code>-gated — see <code>docs/admin-tokens.md</code>) and hand it to the\nagent as <code>UPLOADS_TOKEN</code>, or an enrollment code (<code>upe_…</code>, an alternative invite-link/code path — useful\nwhen you don't have the recipient's email) can be exchanged with <code>uploads login --code</code>.\nNeither is the normal path for new setups.</p>\n<p>The resulting token defaults to 90 days and <code>files:read</code> plus <code>files:write</code>; it cannot\ndelete files unless an administrator explicitly grants <code>files:delete</code>. Verify or inspect\nsetup at any time:</p>\n<pre><code>uploads setup                                  # shows effective configuration\nuploads doctor                                 # version + health + auth + workspace\nuploads doctor --json\n</code></pre>\n<p>Workspace tokens encode their workspace (<code>up_&lt;workspace&gt;_…</code>), so the CLI infers\n<code>--workspace</code> when you don't set it. <code>/account/developers</code> mints the same\ntoken shape and can skip expiry (revoke is then the only off switch). Legacy\nadministrator-minted tokens remain valid.\nSee \"Config commands\" for setting put defaults (default repo, prefix, image\nwidth) once instead of per-command.</p>\n<h2>Core workflow: <code>uploads put</code></h2>\n<p>Upload one or more files and get back URL(s) plus ready-to-paste markdown.\nMultiple paths upload in parallel; multi-file JSON is <code>{ uploads, failures }</code>\n(exit <code>1</code> when any failed). Single-file JSON stays a flat object.</p>\n<pre><code>uploads put ./shot.png --repo myorg/myapp --ref 1722 --alt \"New live feed cards\" --width 700\nuploads put ./before.png ./after.png\n</code></pre>\n<p>Human output goes to stderr; the URL and markdown to stdout, so you can pipe or\ncapture them. Use <code>-</code> as the file to read from stdin.</p>\n<p>Key options (<code>uploads put --help</code> for all):</p>\n<table>\n<thead>\n<tr>\n<th>Flag</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>--alt &lt;text&gt;</code></td>\n<td>Alt text for the markdown (default: filename). Always write meaningful alt text.</td>\n</tr>\n<tr>\n<td><code>--width &lt;px&gt;</code></td>\n<td>Emit sized <code>&lt;img width=…&gt;</code> HTML instead of <code>![]()</code> (markdown can't size images).</td>\n</tr>\n<tr>\n<td><code>--repo &lt;owner/repo&gt;</code></td>\n<td>Repo segment of the auto key (default: git remote, or <code>UPLOADS_DEFAULT_REPO</code>).</td>\n</tr>\n<tr>\n<td><code>--ref &lt;id&gt;</code></td>\n<td>PR/issue/branch/date segment (default: today, or <code>UPLOADS_DEFAULT_REF</code>).</td>\n</tr>\n<tr>\n<td><code>--destination &lt;id&gt;</code></td>\n<td>Typed root: <code>screenshots</code> | <code>gh</code> | <code>f</code> (sets key prefix).</td>\n</tr>\n<tr>\n<td><code>--prefix &lt;path&gt;</code></td>\n<td>Key prefix (default: <code>screenshots</code>, or <code>UPLOADS_DEFAULT_PREFIX</code>).</td>\n</tr>\n<tr>\n<td><code>--key &lt;key&gt;</code></td>\n<td>Set the object key explicitly; skips the auto-naming below.</td>\n</tr>\n<tr>\n<td><code>--name &lt;leaf&gt;</code></td>\n<td>Clean filename for the key's leaf + default alt (no <code>/</code>); keeps the <code>--pr</code>/default path. Not with <code>--key</code>.</td>\n</tr>\n<tr>\n<td><code>--replace</code></td>\n<td>Allow overwriting an existing object on a strict key (<code>--key</code>/default path). No effect on <code>--pr</code>/<code>--issue</code> (or <code>UPLOADS_OVERWRITE=1</code>).</td>\n</tr>\n<tr>\n<td><code>--dry-run</code></td>\n<td>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 <code>--gallery</code>; skips the managed comment sync even with <code>--pr</code>/<code>--issue</code>.</td>\n</tr>\n<tr>\n<td><code>--content-type &lt;mime&gt;</code></td>\n<td>Override the content type (else inferred from extension; ignored when optimize rewrites the body).</td>\n</tr>\n<tr>\n<td><code>--frame &lt;id&gt;</code></td>\n<td>Opt-in chrome before optimize: <code>phone</code>, <code>browser</code>, <code>iphone-16-pro</code>.</td>\n</tr>\n<tr>\n<td><code>--frame-url &lt;url&gt;</code></td>\n<td>Address bar text for <code>--frame browser</code>.</td>\n</tr>\n<tr>\n<td><code>--frame-fit cover\\|contain</code></td>\n<td>How the shot fills the screen (default: <code>cover</code>).</td>\n</tr>\n<tr>\n<td><code>--no-optimize</code></td>\n<td>Skip client-side image optimization (default: still images → WebP). Or <code>UPLOADS_NO_OPTIMIZE=1</code>.</td>\n</tr>\n<tr>\n<td><code>--optimize-max-edge &lt;px&gt;</code></td>\n<td>Max long edge when optimizing (default: 2400).</td>\n</tr>\n<tr>\n<td><code>--optimize-quality &lt;1-100&gt;</code></td>\n<td>WebP quality when optimizing (default: 85).</td>\n</tr>\n<tr>\n<td><code>--keep-exif</code></td>\n<td>Keep EXIF/XMP/ICC when optimizing (default: <strong>strip</strong> for privacy). Or <code>UPLOADS_KEEP_EXIF=1</code>.</td>\n</tr>\n<tr>\n<td><code>--no-git</code></td>\n<td>Don't derive <code>--repo</code> from the git remote (or <code>UPLOADS_NO_GIT=1</code>).</td>\n</tr>\n<tr>\n<td><code>--format human\\|url\\|markdown\\|json</code></td>\n<td>Control stdout. <code>--json</code> (global) forces json.</td>\n</tr>\n<tr>\n<td><code>-w, --workspace &lt;name&gt;</code></td>\n<td>Override workspace (wins over env and token inference).</td>\n</tr>\n</tbody>\n</table>\n<p><strong>Image optimization (default on):</strong> PNG/JPEG and similar still images are re-encoded to\nWebP (long edge capped at 2400px, quality 85) before upload so PR/issue embeds stay\nlean. The object key/filename extension follows the output (e.g. <code>shot.png</code> →\n<code>…/shot.webp</code>). <strong>EXIF/XMP is stripped by default</strong> (public URLs + privacy); pass\n<code>--keep-exif</code> when the discussion needs the embedded image metadata. Animated GIF,\nSVG, video, and non-images are left alone; if the optimized payload is not smaller,\nthe original is uploaded. Use <code>--no-optimize</code> when you need lossless originals.</p>\n<p><strong>Frames (opt-in):</strong> <code>--frame phone</code> (generic bezel), <code>--frame browser</code>, or\n<code>--frame iphone-16-pro</code> (community device art, cached under\n<code>~/.cache/uploads/frames</code>). Default is <strong>no frame</strong>.</p>\n<p><strong>How keys work</strong> — three paths, no extra naming modes:</p>\n<table>\n<thead>\n<tr>\n<th>Intent</th>\n<th>Command</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td>Just upload it, give me a URL</td>\n<td><code>uploads put ./file.png</code></td>\n</tr>\n<tr>\n<td>Explicit typed destination</td>\n<td><code>uploads put ./file.png --destination screenshots</code></td>\n</tr>\n<tr>\n<td>Stable GitHub embed I might re-upload</td>\n<td><code>uploads put ./file.png --pr &lt;num&gt;</code></td>\n</tr>\n<tr>\n<td>Stable <code>--pr</code> path but a clean leaf</td>\n<td><code>uploads put ./capture-2026-…Z.png --pr &lt;num&gt; --name hero.png</code></td>\n</tr>\n<tr>\n<td>I know exactly where it goes</td>\n<td><code>uploads put ./file.png --key screenshots/…/x.png</code></td>\n</tr>\n</tbody>\n</table>\n<p>Timestamped captures break stable <code>--pr</code> keys — pass <code>--name hero.webp</code> to keep a\nclean leaf. Use <code>--dry-run</code> to preview the exact public URL before uploading.</p>\n<p>Default <code>put</code> is the fast path; you don't need <code>--key</code>, <code>--prefix</code>, or <code>--repo</code>.\n<strong>Inside a git repo, on a non-default branch, a bare <code>put</code> now stages\nautomatically</strong> — same key/metadata as <code>attach --branch</code>\n(<code>gh/&lt;owner&gt;/&lt;repo&gt;/branch/&lt;branch&gt;/&lt;filename&gt;</code>), so it auto-attaches to that\nbranch's PR when one opens. This fires whenever none of\n<code>--pr</code>/<code>--issue</code>/<code>--key</code>/<code>--ref</code>/<code>--prefix</code>/<code>--destination</code> is set and\n<code>--no-git</code> isn't passed; any of those flags (or the default branch, detached\nHEAD, not being in a git repo, or <code>--no-git</code>) falls back to the classic\n<strong>dated</strong> layout:\n<code>&lt;prefix&gt;/&lt;repo-name&gt;/&lt;ref-or-date&gt;/&lt;basename&gt;-&lt;shorthash&gt;.&lt;ext&gt;</code> — the short\nhash prevents collisions without random names or a separate \"preserve name\"\nflag. Prefer <code>--destination screenshots</code> (or <code>gh</code> with <code>--pr</code>/<code>--issue</code>) over\ninventing roots — workspaces may allowlist only those destinations. Override\nwith <code>--key</code> only when you have a reason, and keep the key under an allowed\nroot. Pass <code>--ref</code>/<code>--prefix</code>/<code>--destination</code> explicitly for a plain dated\nupload on a branch (the opt-out).</p>\n<p><strong>Output formats</strong> — pick what you'll consume:</p>\n<pre><code>uploads put ./shot.png --format url        # just the URL, for scripting\nuploads put ./shot.png --format markdown   # just the ![]()/&lt;img&gt; snippet\nuploads put ./shot.png --json              # {workspace,key,url,size,markdown}\n</code></pre>\n<p><strong>The bare-put staging note.</strong> Since a bare <code>put</code> on a\nnon-default branch now stages by default (see above), it prints a one-line\nnote confirming that instead of nudging you to do it yourself — human mode\nwrites it to stderr, <code>--format json</code> adds it as an additive optional <code>hint</code>\nfield on the same response:</p>\n<pre><code>note: staged for branch fix-header — auto-comments to pull request when opened\n(or run: uploads attach --promote once it exists). Use --ref/--prefix for a\nplain dated upload.\n</code></pre>\n<p>If the same call also trips the stage-time binding warning (issue #398/#400\n— the repo isn't bound to this workspace), that warning takes the <code>hint</code>\nslot instead (it's the more actionable of the two); both still print on\nstderr in human mode. Suppress the note (not the staging itself) with\n<code>--quiet</code>, <code>UPLOADS_NO_NUDGE=1</code> (env), or <code>UPLOADS_NO_NUDGE=1</code> in the config\nfile (<code>uploads config set UPLOADS_NO_NUDGE 1</code>).</p>\n<p><strong>The old \"rerun with --pr\" nudge (issue #393)</strong> still fires, unchanged, for\nthe narrower case a bare put still lands on the dated layout with a\ndetectable PR — in practice, an explicit <code>--ref</code>/<code>--prefix</code> opting out of\nstaging while a PR is open for that branch:</p>\n<pre><code>note: on branch fix-header (PR #142 open) — rerun with --pr 142 for a stable\nkey plus a managed comment that collects this PR's media, or stage pre-PR\nfiles with: uploads attach &lt;file&gt; --branch\n</code></pre>\n<p>It's best-effort (a quick <code>gh pr view</code> lookup, bounded to 3s) — no open PR\njust widens the wording to a generic <code>--pr &lt;num&gt;</code>. Same suppression as above.</p>\n<h2>Capturing a screenshot: <code>uploads screenshot</code></h2>\n<p>Capture a URL or a local <code>.html</code> file and host it — no separate screenshot\ntool needed, and no browser install required for the default path:</p>\n<pre><code>uploads screenshot https://uploads.sh --pr 128\nuploads screenshot ./card.html --out ./card.png\nuploads screenshot ./card.html --no-upload --out ./card.png\n</code></pre>\n<p>After capture, a screenshot shares the exact <code>put</code> upload pipeline described\nabove: optional <code>--frame</code>, optimize-by-default, <code>--pr</code>/<code>--issue</code> attachment +\n<code>--comment</code>, <code>--gallery</code>, <code>--meta</code>, and the same output formats. It also\nships as an MCP tool (<code>screenshot</code>) alongside the CLI command.</p>\n<p><strong>Two capture backends</strong>, selected with <code>--via</code>:</p>\n<table>\n<thead>\n<tr>\n<th>Backend</th>\n<th>What it is</th>\n<th>Needs</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>local</code></td>\n<td>Drives an already-installed Chrome/Chromium via <code>playwright-core</code></td>\n<td>A discoverable browser on disk, or <code>--cdp</code></td>\n</tr>\n<tr>\n<td><code>remote</code></td>\n<td>Renders server-side via the uploads.sh render endpoint</td>\n<td>Nothing local; counts against the workspace's monthly upload budget</td>\n</tr>\n</tbody>\n</table>\n<p><code>--via auto</code> (the default) prefers local when a usable browser is found,\nelse falls back to remote. Set a persistent default with\n<code>UPLOADS_SCREENSHOT_VIA=auto|local|remote</code> (env, <code>--env-file</code>, or the user\nconfig file — see \"Config commands\"); the <code>--via</code> flag always wins.</p>\n<p><strong>localhost/private-network targets are local-only.</strong> With <code>--via remote</code>\n(or <code>auto</code> falling back to remote) these fail fast with a clear error instead\nof sending a request that could never work. Local <code>.html</code> files work on both\nbackends — the remote backend receives the file's contents inline (≤ 2 MiB),\nso anything the page references via <code>file://</code> or relative paths only resolves\nwith <code>--via local</code>. A numeric\n<code>--wait &lt;ms&gt;</code> (fixed settle delay after load) is also local-only; use\n<code>--wait load|domcontentloaded|networkidle</code> for a backend-agnostic wait.</p>\n<p>Use <code>--cdp &lt;endpoint&gt;</code> to attach to a Chrome that's already running\n(<code>http://host:port</code> or <code>ws://…</code>) instead of launching a new one — handy when\nan agent already has a Playwright MCP or <code>agent-browser</code> session open.\n<code>--browser &lt;path&gt;</code> (or <code>UPLOADS_CHROME_PATH</code> / <code>CHROME_PATH</code>) points at an\nexplicit executable.</p>\n<p>Key options (<code>uploads screenshot --help</code> for all):</p>\n<table>\n<thead>\n<tr>\n<th>Flag</th>\n<th>Purpose</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>--via auto\\|local\\|remote</code></td>\n<td>Capture backend (default: <code>auto</code>, or <code>UPLOADS_SCREENSHOT_VIA</code>).</td>\n</tr>\n<tr>\n<td><code>--browser &lt;path&gt;</code></td>\n<td>Explicit local browser executable (or <code>UPLOADS_CHROME_PATH</code> / <code>CHROME_PATH</code>).</td>\n</tr>\n<tr>\n<td><code>--cdp &lt;endpoint&gt;</code></td>\n<td>Attach to a running Chrome via CDP instead of launching one (local backend only).</td>\n</tr>\n<tr>\n<td><code>--viewport &lt;WxH[@Sx]&gt;</code></td>\n<td>Size + device scale factor (default: <code>1280x800@2</code>).</td>\n</tr>\n<tr>\n<td><code>--selector &lt;css&gt;</code></td>\n<td>Capture one element instead of the viewport.</td>\n</tr>\n<tr>\n<td><code>--full-page</code></td>\n<td>Capture the full scrollable page.</td>\n</tr>\n<tr>\n<td><code>--max-height &lt;px&gt;</code></td>\n<td>Cap on <code>--full-page</code> capture height in CSS px (default: <code>5000</code>; <code>0</code> = uncapped). A page over the cap is clipped, with a note to stderr and a <code>--format json</code> <code>hint</code>. Requires <code>--full-page</code>; applies on both <code>--via local</code> and <code>--via remote</code>.</td>\n</tr>\n<tr>\n<td><code>--dark</code> / <code>--light</code></td>\n<td>Emulate <code>prefers-color-scheme</code> (full media-query emulation on <code>--via local</code> only — <code>--via remote</code> only sets the CSS <code>color-scheme</code> property, so a page's own <code>prefers-color-scheme</code> queries won't flip).</td>\n</tr>\n<tr>\n<td><code>--wait &lt;load\\|domcontentloaded\\|networkidle\\|ms&gt;</code></td>\n<td>Settle strategy (default: <code>load</code>); a millisecond count is local-only.</td>\n</tr>\n<tr>\n<td><code>--wait-for &lt;js&gt;</code></td>\n<td>Poll this JS expression in the page until truthy before <code>--eval</code> and capture (local backend only). Bridges framework hydration — <code>load</code>/<code>networkidle</code> settle before React/Next attach handlers, so a synthetic click in <code>--eval</code> hits the inert SSR DOM. Express the app's own signal, e.g. <code>--wait-for 'window.__hydrated===true'</code>. Times out with the capture timeout.</td>\n</tr>\n<tr>\n<td><code>--eval &lt;js&gt;</code> / <code>--init-script &lt;file&gt;</code></td>\n<td>Run setup JS after settle / inject a script before navigation (local backend only). Synthetic events (<code>el.click()</code>) won't reach framework handlers until the app hydrates — pair <code>--eval</code> with <code>--wait-for</code> on React/Next apps.</td>\n</tr>\n<tr>\n<td><code>--out &lt;file&gt;</code></td>\n<td>Also write the PNG to a local file, plus a sidecar manifest (<code>&lt;file&gt;.uploads.json</code>) with this capture's derived metadata (<code>path</code>/<code>url</code>/<code>env</code>/<code>viewport</code>, plus <code>--state</code> if given) and a content hash. A later <code>put</code>/<code>attach</code> of that exact file picks the metadata back up automatically — explicit <code>--meta</code>/<code>--state</code> still win. See <code>--no-sidecar</code>.</td>\n</tr>\n<tr>\n<td><code>--no-sidecar</code></td>\n<td>Don't write the <code>&lt;file&gt;.uploads.json</code> sidecar alongside <code>--out</code>.</td>\n</tr>\n<tr>\n<td><code>--no-upload</code></td>\n<td>Skip hosting; requires <code>--out</code> (local file only).</td>\n</tr>\n<tr>\n<td><code>--key</code> / <code>--pr</code> / <code>--issue</code> / <code>--comment</code></td>\n<td>Same destination and attachment options as <code>put</code> (see above); <code>--pr</code>/<code>--issue</code> also give a stable, hash-free key.</td>\n</tr>\n<tr>\n<td><code>--branch [name]</code></td>\n<td>Stage against a branch, pre-PR — same key as <code>attach --branch</code> (see below); this is also what a bare <code>screenshot</code> on a non-default branch does automatically.</td>\n</tr>\n<tr>\n<td><code>--frame</code> / <code>--no-optimize</code> / <code>--gallery</code> / <code>--meta</code></td>\n<td>Same as <code>put</code> — reused from the shared upload pipeline.</td>\n</tr>\n</tbody>\n</table>\n<p><strong>Inside a git repo, on a non-default branch, a bare <code>screenshot</code> now stages\nautomatically too</strong> — same\nkey/metadata as <code>--branch</code>/<code>attach --branch</code>\n(<code>gh/&lt;owner&gt;/&lt;repo&gt;/branch/&lt;branch&gt;/&lt;filename&gt;</code>), carrying every derived fact\n(<code>path</code>/<code>url</code>/<code>env</code>/<code>viewport</code>, plus <code>--state</code>) through to the PR once it\nopens. This is what closes the gap a coding agent hits capturing before the PR\nexists: capture early with a plain <code>uploads screenshot &lt;url&gt; --out shot.png</code>,\nand the metadata rides along instead of being re-stated (or lost) at\n<code>attach --pr &lt;num&gt;</code> time. Fires whenever none of\n<code>--pr</code>/<code>--issue</code>/<code>--branch</code>/<code>--key</code>/<code>--ref</code>/<code>--prefix</code>/<code>--destination</code> is set\nand <code>--no-git</code> isn't passed; the same set of flags (or the default branch,\ndetached HEAD, not being in a git repo, or <code>--no-git</code>) falls back to the\nclassic dated <code>screenshots/&lt;repo&gt;/&lt;date&gt;/...</code> layout. Prints the same\nstaging note as bare <code>put</code> (see above) — same stderr wording, same JSON\n<code>hint</code> field, same <code>--quiet</code>/<code>UPLOADS_NO_NUDGE</code> suppression.</p>\n<p><strong>Errors and hints:</strong> a local capture with no usable browser fails with\n<code>BROWSER_NOT_FOUND</code> (exit <code>2</code>) — hint: try <code>--via remote</code>, or install a\nbrowser (<code>npx playwright install chromium</code>). A remote render that the server\ncan't complete returns <code>RENDER_FAILED</code>. A burst rate limit on the render\nendpoint returns <code>RATE_LIMITED</code> (exit <code>4</code>) — hint: wait ~60s and retry. A\nremote render over the workspace's monthly upload budget surfaces the usual\n<code>UPLOAD_BUDGET</code> code and hint (<code>uploads usage</code>, then delete objects or raise\nlimits) — renders and puts share one monthly counter.</p>\n<p><code>uploads doctor</code> reports which local browser (if any) was detected and which\nbackend <code>--via auto</code> would currently pick.</p>\n<p><strong>Key derivation and <code>--state</code>.</strong> Whenever the object's filename is\nauto-derived from the captured URL (host + path) — the default dated\nlayout, or the <code>--pr</code>/<code>--issue</code> leaf name — passing <code>--state</code> folds it into\nthat derived filename stem —\n<code>localhost-docs-mcp.webp</code> becomes <code>localhost-docs-mcp-before.webp</code> /\n<code>localhost-docs-mcp-after.webp</code> — so capturing the same URL twice with\n<code>--state before</code> then <code>--state after</code> produces two distinct objects instead\nof the second silently overwriting the first. Re-capturing the same URL with\nthe <em>same</em> state still replaces the existing object in place (idempotent\nre-capture). An explicit <code>--key</code> is unaffected by <code>--state</code> folding. On\noverwrite, human mode prints <code>&gt;&gt; replaced existing object (same URL)</code> to\nstderr (same wording as <code>put</code>'s hot-swap note, above) and <code>--format json</code>\nadds <code>\"replaced\": true</code>, plus a <code>hint</code> field when a <code>--state</code> capture\nreplaced an existing object.</p>\n<h3>Baking in callouts: <code>--annotate</code></h3>\n<p><code>--annotate &lt;file|-&gt;</code> bakes hand-drawn boxes, arrows, labels, freeform\nstrokes, and redactions onto the capture before it's uploaded (JSON spec, a\nfile path or <code>-</code> for stdin). Selectors resolve against the live page — the\nlocal backend only in v1, so a selector-bearing spec on <code>--via remote</code> is\nrejected up front:</p>\n<pre><code>uploads screenshot http://localhost:3000 --via local --annotate ./callouts.json\n</code></pre>\n<p>For the spec format and an existing-image equivalent (<code>uploads annotate &lt;image&gt; --spec &lt;file|-&gt;</code>, pixel-only, no selectors), see the\n<strong>annotate-screenshots</strong> skill.</p>\n<h2>Custom metadata &amp; search</h2>\n<p>Every object can carry queryable key-value metadata (distinct from optimize/frame\nprovenance) — tag uploads at put time, then find them later.</p>\n<h3>The canonical vocabulary</h3>\n<p>Metadata is only useful if it is spelled the same way every time. These ten keys\nare the agreed vocabulary; <strong>most are derived for you</strong>, so the main job is not\nto fight them by inventing a different spelling.</p>\n<table>\n<thead>\n<tr>\n<th>Key</th>\n<th>Source</th>\n<th>Example</th>\n</tr>\n</thead>\n<tbody>\n<tr>\n<td><code>url</code></td>\n<td>auto — screenshot target</td>\n<td><code>https://app.example/settings</code></td>\n</tr>\n<tr>\n<td><code>path</code></td>\n<td>auto — pathname</td>\n<td><code>/settings</code></td>\n</tr>\n<tr>\n<td><code>env</code></td>\n<td>auto — <strong><code>local</code> only</strong></td>\n<td><code>local</code></td>\n</tr>\n<tr>\n<td><code>theme</code></td>\n<td>auto — only when forced</td>\n<td><code>dark</code></td>\n</tr>\n<tr>\n<td><code>viewport</code></td>\n<td>auto — capture opts / EXIF</td>\n<td><code>1280x800@2x</code></td>\n</tr>\n<tr>\n<td><code>device</code></td>\n<td>auto — image EXIF</td>\n<td><code>Apple iPhone 16 Pro</code></td>\n</tr>\n<tr>\n<td><code>software</code></td>\n<td>auto — image EXIF</td>\n<td><code>Figma</code></td>\n</tr>\n<tr>\n<td><code>captured</code></td>\n<td>auto — image EXIF</td>\n<td><code>2026-07-20T20:35:39</code></td>\n</tr>\n<tr>\n<td><code>state</code></td>\n<td><strong>you</strong> — <code>--state</code></td>\n<td><code>before</code> | <code>after</code></td>\n</tr>\n<tr>\n<td><code>app</code></td>\n<td><strong>you</strong> — <code>--app</code></td>\n<td><code>web</code> | <code>ios</code></td>\n</tr>\n</tbody>\n</table>\n<p><strong><code>path</code> and <code>state</code> are the two highest-value keys — pass both, every time,\nas a habit.</strong> <code>path</code> is the key most worth getting right (the one agents most\noften misspell as <code>route</code>, <code>page</code>, or <code>screen</code> — a near-miss warns on stderr\nand suggests the canonical spelling, but is never rewritten for you, so fix it\nat the source) and <code>state</code> captures the before/after pattern that dominates\nPR screenshots — nothing can infer either one from the image alone. <code>state</code> is\na closed set: <code>before</code>, <code>after</code>, <code>empty</code>, <code>error</code>, <code>loading</code> (a near-miss like\n<code>--state post</code> fails fast and suggests <code>after</code>).</p>\n<p><code>uploads screenshot</code> derives <code>path</code> automatically from the captured URL, so\nyou only need to add <code>--state</code>. <code>uploads put</code>/<code>uploads attach</code> of an\nalready-existing file have nothing to derive <code>path</code> from, so pass it\nexplicitly with <code>--meta path=/route</code> — and <code>attach</code>/<code>put --pr</code>/<code>put --issue</code>\nprint a <code>tip: add --meta path=/route so this shot is findable by page</code> on\nstderr (plus a JSON <code>hint</code> field) when an image lands with no <code>path</code> meta, as\na reminder (respects <code>--quiet</code>).</p>\n<pre><code>uploads screenshot https://app.example/settings --state before\n# → stamps url, path=/settings, viewport, state=before\n\nuploads put ./after.png --pr 123 --meta path=/settings --state after --app web\nuploads find path=/settings state=after        # what it was all for\n</code></pre>\n<h3>What is derived, and when</h3>\n<ul>\n<li><strong><code>uploads screenshot</code></strong> knows its own target, so it stamps <code>url</code>, <code>path</code>\n(query stripped), <code>viewport</code>, <code>env=local</code> for a local target, and <code>theme</code>\nwhen <code>--dark</code>/<code>--light</code> forced one.</li>\n<li><strong><code>uploads put</code>/<code>attach</code></strong> read the image's own EXIF <em>before</em> the optimizer\nstrips it, promoting an allowlist: <code>viewport</code> (from pixel dimensions and DPI),\n<code>device</code>, <code>software</code>, <code>captured</code>.</li>\n<li><strong><code>uploads put</code>/<code>attach</code> also read a sidecar manifest</strong> left by a prior\n<code>screenshot --out</code> of that exact file (<code>&lt;file&gt;.uploads.json</code>, content-hash\nguarded — a regenerated/edited file silently loses it) and merge in its\nderived metadata. This closes the capture-then-attach gap where a shot is\ntaken before a PR exists: <code>uploads screenshot ... --out shot.png --state after</code>\nnow, <code>uploads attach shot.png --pr 123</code> later, still gets <code>path</code>/<code>url</code>/<code>env</code>/\n<code>viewport</code>/<code>state</code> on the PR-keyed object. Disable writing it with\n<code>--no-sidecar</code>.</li>\n</ul>\n<p><code>env</code> is only ever <code>local</code>. It is never set to <code>prod</code> — inferring that from \"not\nlocalhost\" would mislabel every staging and preview URL, and wrong metadata is\nworse than none.</p>\n<p><strong>Never promoted from EXIF, regardless of <code>--keep-exif</code>:</strong> all GPS tags, body and\nlens serial numbers, <code>Artist</code>/<code>Copyright</code>/<code>OwnerName</code>, and free-form user\ncomments. Note the flip side: <code>device</code> and <code>software</code> were previously discarded\nand now become queryable metadata that renders on the public <code>/f/</code> page.</p>\n<p><strong>Precedence:</strong> explicit <code>--meta</code>/<code>--state</code>/<code>--app</code> &gt; screenshot capture facts &gt;\nsidecar manifest &gt; EXIF &gt; unset. Derived keys are also dropped first if the\n24-key cap is reached — your own keys are never dropped, and a full key\nbudget never fails an upload.</p>\n<p>Turn the whole derived tier off with <code>--no-auto</code> or <code>UPLOADS_NO_AUTO_META=1</code>.</p>\n<h3>Rules and reserved keys</h3>\n<p>Validated client-side, fail-fast, before uploading: key\n<code>^[a-z][a-z0-9._-]{0,63}$</code> (lowercase, dot-namespacing allowed, e.g. <code>gh.repo</code>);\nvalue 1–512 printable ASCII characters; <code>--meta k=v</code> may repeat up to 24 times per\nrequest; a value may itself contain <code>=</code> (only the first <code>=</code> splits key from value).\n<code>content-sha256</code> and <code>visibility</code> are reserved (server-computed / the real R2\nvisibility gate, respectively). <code>uploads attach</code> writes its own <code>gh.*</code>\nreserved-by-convention keys automatically — see below.</p>\n<p><code>meta set</code> on a <code>gh/…</code>-keyed object also refreshes the managed PR/issue comment\nwhenever the write touches a rendered key (<code>path</code>/<code>state</code>) — best-effort, after\nthe metadata write already lands. On success it prints\n<code>refreshed the managed comment on &lt;repo&gt;#&lt;num&gt;</code> to stderr; if the bot endpoint\nis unavailable it prints <code>tip: run \\</code>uploads comment --pr </p>\n<pre><code>uploads meta get screenshots/myapp/42/settings.webp\nuploads meta set screenshots/myapp/42/settings.webp path=/onboarding --delete url\nuploads meta set screenshots/myapp/42/settings.webp --meta path=/onboarding  # same thing\nuploads list --meta app=web --meta path=/settings   # ANDed, repeatable\nuploads find app=web path=/settings                 # same filter, positional pairs\nuploads find --meta app=web                         # --meta works here too\nuploads find hero                                   # bare name = filename substring\nuploads find --name hero --meta app=web             # name + meta, either order\nuploads meta keys                                   # which meta keys exist here\nuploads meta values app                             # values (with counts) for one key\n</code></pre>\n<p><code>meta set</code> and <code>find</code> accept pairs in either spelling: positional <code>k=v</code>, or the\nrepeatable <code>--meta k=v</code> that <code>put</code>, <code>attach</code>, <code>screenshot</code>, and <code>list</code> use. Both\nforms can appear in one call. <code>find</code> also takes a case-insensitive filename\nsubstring (<code>--name &lt;term&gt;</code>, or a bare positional without <code>=</code>). When you don't\nknow which keys exist, start with <code>meta keys</code> / <code>meta values &lt;key&gt;</code> (or the MCP\n<code>list_metadata_keys</code> tool) — keys are user/agent-defined, not a fixed schema.</p>\n<p>On the default <code>screenshots/…</code> path, <code>put</code> also auto-derives GitHub context and\nstamps <code>gh.repo</code>/<code>gh.kind</code>/<code>gh.number</code>/<code>gh.ref</code> from the current branch's PR (or\na numeric <code>--ref</code>), so the file's <code>/f/</code> page shows an \"Attached to\" link. This is\non by default and best-effort; disable it with <code>--no-auto</code>, <code>--no-git</code>, or <code>UPLOADS_NO_AUTO_META=1</code>.\nOn this auto path an explicit <code>--meta gh.*</code> overrides the auto-derived value — the\nopposite of the <code>--pr</code>/<code>--issue</code> precedence below, where the target's own <code>gh.*</code> always wins.\nBoth paths also stamp <code>gh.title</code> with the resolved PR/issue title when local <code>gh</code>\ncan resolve one — best-effort, never blocks the upload if it can't.</p>\n<p><strong>Re-upload semantics:</strong> re-uploading to an existing key <strong>with</strong> metadata replaces\nthat file's entire metadata set (delete-then-set, not a merge); re-uploading with\n<strong>no</strong> metadata at all preserves the existing metadata untouched. Derived keys\ncount as metadata here, so a re-upload that derives anything replaces the set —\npass <code>--no-auto</code> when re-uploading a key whose metadata you curated with\n<code>uploads meta set</code>.</p>\n<p>Non-<code>gh.*</code> metadata values supplied via <code>--meta</code> (CLI) or <code>metadata</code> (MCP) render\non the object's public <code>/f/&lt;key&gt;</code> file page. Treat them like the URL itself:\ndon't put internal notes, secrets, tokens, IDs, or private paths in them.</p>\n<h2>Public media galleries</h2>\n<p>Use galleries when several existing public uploads should be shared as one ordered collection.\nA gallery has an opaque, API-returned public URL; do not derive one in scripts. <strong>Anyone who\nknows the URL can view the gallery and its media</strong>. GitHub repository visibility does not make\nit private, and a gallery does not pin objects against retention.</p>\n<pre><code>uploads gallery create --title \"Settings redesign\"\nuploads gallery add gal_example screenshots/app/settings-before.webp screenshots/app/settings-after.webp\nuploads put ./after.png --gallery gal_example --alt \"Updated settings page\"\nuploads gallery show gal_example\nuploads gallery link gal_example --github buildinternet/uploads#58\nuploads gallery list --github https://github.com/buildinternet/uploads/pull/58\n</code></pre>\n<p><code>gallery add</code> processes keys sequentially so it obtains a current optimistic version before\neach mutation. With <code>--json</code>, its stable <code>added</code> and <code>failures</code> arrays make partial failures\nsafe for agents to inspect. A workspace may have up to 100 active galleries; each gallery permits up to 100 items and 20 linked external references. Deleting a gallery removes only its gallery record—not the objects.</p>\n<p>Optionally link a gallery to a GitHub issue or PR with <code>uploads gallery link &lt;gallery-id&gt; --github &lt;owner/repo#number&gt;</code>. The CLI also accepts strict <code>https://github.com/&lt;owner&gt;/&lt;repo&gt;/issues|pull/&lt;number&gt;</code> URLs. Use <code>uploads gallery list --github &lt;coordinate-or-url&gt;</code> for the authenticated reverse lookup. This is metadata only: it does not make a gallery private or change its opaque identity.</p>\n<h2>Embedding in a GitHub PR or issue</h2>\n<p>Two ways, depending on whether you want a durable URL, a managed comment, or both.</p>\n<h3>Option A — stable attachment URL (<code>--pr</code> / <code>--issue</code>)</h3>\n<p>Gives the file a <strong>hash-free, stable key</strong> so re-uploads overwrite in place and the\nURL is safe to hard-code in a PR body you'll edit later:</p>\n<pre><code>uploads put ./after.png --pr 123 --alt \"Dashboard after\"\n# key: gh/&lt;owner&gt;/&lt;repo&gt;/pull/123/after.webp  → stable public URL (PNG optimized to WebP)\n</code></pre>\n<p><code>--issue &lt;num&gt;</code> does the same under <code>.../issues/&lt;num&gt;/</code>. The <code>&lt;owner&gt;/&lt;repo&gt;</code> comes\nfrom <code>--repo</code> or the git remote. <code>--pr</code>/<code>--issue</code> can't be combined with <code>--key</code>,\n<code>--ref</code>, or <code>--prefix</code> (the key layout is fixed), and are mutually exclusive.</p>\n<p>These keys are deliberately predictable: they include the owner, repository, PR or\nissue number, and filename. uploads.sh does not check GitHub visibility, so a private\nor internal repository does <strong>not</strong> make the uploaded file private. Before using this\nmode, confirm the media is safe for a public, guessable URL; otherwise redact it or do\nnot upload it.</p>\n<p>If the uploads GitHub App can see that the target repo is private, this key layout\nchanges automatically: the key becomes <code>gh/private/&lt;id&gt;/...</code>, where <code>&lt;id&gt;</code> is a random\nid minted per branch (or per repo, for issues), instead of the derivable\n<code>gh/&lt;owner&gt;/&lt;repo&gt;/...</code>. No flag needed — public repos, and repos the App can't see,\nkeep the derivable layout. The URL is still unauthenticated and durable, not\naccess-controlled — anyone who obtains it can read it until you rotate the id with\n<code>uploads github rotate-prefix --branch &lt;branch&gt;</code> (or <code>--repo-level</code> for issues/ingested\nassets). See <code>docs/private-attachments.md</code> for the full threat model.</p>\n<p>Then reference the <strong>embed</strong> URL in the PR/issue markdown you write with <code>gh</code>\n(CLI <code>--format markdown</code> / MCP <code>markdown</code> already do this):</p>\n<pre><code>&lt;img width=\"700\" alt=\"Dashboard after\" src=\"https://embed.uploads.sh/default/gh/myorg/myapp/pull/123/after.webp\"&gt;\n</code></pre>\n<p>Keep <code>url</code> (storage host) when you need a durable share link outside GitHub.</p>\n<p><code>put --pr</code>/<code>--issue</code> (and <code>uploads attach</code>, below) writes <code>gh.repo</code>/<code>gh.kind</code>/\n<code>gh.number</code>/<code>gh.ref</code> as queryable metadata automatically, so <code>uploads find gh.ref=myorg/myapp#123</code> or <code>uploads list --meta gh.repo=myorg/myapp</code> finds\neverything attached to that PR/issue without needing the <code>gh/...</code> prefix. Add\n<code>--meta k=v</code> extras for your own pairs on top — a <code>--meta gh.*</code> override loses\nto the target's own <code>gh.*</code> values. It also stamps <code>gh.title</code> with the real\nPR/issue title when resolvable via local <code>gh</code> (best-effort; omitted rather\nthan failing the upload if <code>gh</code> can't resolve one).</p>\n<h3>Option B — managed attachments comment (default with <code>--pr</code>/<code>--issue</code>, or <code>comment</code>)</h3>\n<p><code>put --pr</code>/<code>--issue</code> (like <code>attach</code>) uploads <strong>and</strong> creates/updates a single marker-owned comment on the PR/issue by default — no separate flag needed. It keeps loose <code>gh/...</code> attachments and every public gallery linked to that PR/issue in clearly separate sections, with up to three available gallery images inline. It finds its own prior comment via a hidden marker and edits it in place — it never touches the description or other comments:</p>\n<pre><code>uploads put ./after.png --pr 123\n</code></pre>\n<p>Pass <code>--no-comment</code> to skip the sync (upload only), matching <code>attach --no-comment</code>. <code>--comment</code> is still accepted on <code>put</code> as a no-op — it's redundant now that the sync is the default, kept only for scripts written before this changed (#537).</p>\n<p>The upload is authoritative; the comment is best-effort — if <code>gh</code> is missing or\nunauthenticated, the upload still succeeds and you get a warning. To (re)sync the\ncomment without uploading anything (e.g. after several <code>--pr</code> uploads or gallery links), use the standalone command:</p>\n<pre><code>uploads comment --pr 123\nuploads comment --issue 45 --repo buildinternet/uploads\n</code></pre>\n<p>Removed the wrong screenshots? <code>delete</code> the object(s) and re-run <code>comment</code> to\nre-sync. When the <strong>last</strong> attachment and gallery are gone, the managed comment\nis rewritten in place to a neutral empty state (<code>No attachments are currently associated with this pull request.</code>) — it is never deleted (a later upload\nrepopulates it) and never created just to say it's empty:</p>\n<pre><code>uploads delete gh/owner/name/pull/123/after.png   # remove the asset\nuploads comment --pr 123                           # comment now shows the empty state\n</code></pre>\n<p>Past 16 inline images, the comment collapses the rest into a <code>&lt;details&gt;</code> link\nlist so a heavily-screenshotted PR stays readable. Each workspace gets its own\nmanaged comment on a shared repo (namespaced under the hood) instead of\nclobbering another workspace's — use <code>uploads github link</code> (below) to see or\nset which workspace a repo is bound to.</p>\n<h3>Repo binding (<code>uploads github link</code> / <code>unlink</code> / <code>doctor</code>)</h3>\n<p>The managed comment and webhook auto-promotion use a first-claim-wins binding\nbetween a repo and a workspace, normally created implicitly by your first\n<code>comment</code>/<code>put --comment</code>/promote call. Inspect, claim, or release it:</p>\n<pre><code>uploads github link                       # claim the current repo for this workspace\nuploads github link --repo owner/name     # claim a specific repo\nuploads github link --status              # read-only: show the current binding, don't claim\nuploads github unlink --repo owner/name   # release a binding this workspace owns\nuploads github doctor                     # check the App itself (config + webhook events)\n</code></pre>\n<p>Claiming an already-bound repo never steals it — the command reports the\nexisting owner instead. <code>unlink</code> only releases a binding this workspace owns;\nit 403s if another workspace owns it (an operator can reassign or remove it\nfrom the admin panel instead). On an older/self-hosted server without these\nroutes they fail with a clear \"server does not support repo bindings/GitHub\nApp health check yet\" message.</p>\n<p>Claiming an <em>unbound</em> repo is authorized, not just first-come (issue #297):\nthe server only lets a workspace make that first claim when its linked\nGitHub account has push (or higher) access to the repo, checked live against\nGitHub via the App's installation token. A token with no linked GitHub\nidentity — a legacy/enrollment/shared token, including <code>default</code>'s — can\nnever claim a new repo, though it keeps working normally on any repo already\nbound to it. Claiming reports <code>claimed: false, reason: \"not_authorized\"</code> when\nthis check fails; link a GitHub account with push access to the repo, or ask\nan operator to bind it explicitly from the admin panel.</p>\n<p><strong><code>not_authorized</code> on <code>comment</code>/<code>attach --comment</code></strong> means either the repo is\nbound to a different workspace, or it's unbound and this workspace couldn't\nbe verified as entitled to claim it (see above). Either way it's a hard\ndecline, not a degrade — the CLI does <strong>not</strong> fall back to posting via local\n<code>gh</code> in this case, unlike other bot-post failures. Run <code>uploads github link --status</code> to see the current binding (if any), switch to a workspace with a\nlinked GitHub account that has access, or ask an operator to bind the repo\nexplicitly.</p>\n<p><code>uploads github doctor</code> checks the App's own configuration and webhook event\nsubscriptions (needs <code>issues</code> + <code>pull_request</code>; <code>issue_comment</code> is\nrecommended so a deleted/mangled bot comment self-heals instead of waiting\nfor the next PR push) — useful when webhook-driven behavior (auto-promotion,\ntitle updates, self-healing) seems to be silently doing nothing.</p>\n<h3>Mirroring GitHub-native attachments (<code>uploads ingest</code>)</h3>\n<p>Images someone drops straight into a PR/issue via <code>github.com/user-attachments/…</code>\nonly exist behind GitHub's own authenticated hosting — they're never public\nURLs. <code>uploads ingest --pr &lt;n&gt;</code> (or <code>--issue &lt;n&gt;</code>) scans the description and\ncomments for that media, mirrors any new ones into the workspace (indexed,\nnot added to the managed comment), and detaches ones no longer referenced —\na reattached one un-detaches without a re-fetch:</p>\n<pre><code>uploads ingest --pr 123\nuploads ingest --issue 45 --repo owner/name --json\n</code></pre>\n<p>Requires the repo be linked to the workspace (<code>uploads github link</code>) and the\nGitHub App installed — otherwise it fails with a clear error rather than\nguessing. This is the manual/backfill entry point; the <code>.uploads.yml</code>\n<code>ingestGithubAttachments</code> knob only gates the automatic webhook path and has\nno effect on running <code>ingest</code> directly.</p>\n<h3>Embedding best practices</h3>\n<ul>\n<li><strong>Meaningful alt text</strong>, always — it's w</li>\n</ul>\n","files":[{"path":"SKILL.md","sizeBytes":82643,"isText":true}],"reviewScore":null,"reviewSummary":null,"trust":{"provenance":"trusted-source-unreviewed","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow.","bodySource":null},"bodyLocked":false,"purchaseUrl":null,"sourceUrl":null,"report":{"provenance":"trusted-source-unreviewed","screen":{"ran":true,"outcome":"clean","suspicious":0,"notes":0,"hiddenCharacters":false},"virusScan":{"engine":"clamav","status":"clean","scannedAt":"2026-09-27T19:36:55.759146Z","sha256":"1E85FFC5540D81A1CC8FF638DC6184D422F74A0AA94CDF9509561C2A1C24F4DD","sizeBytes":26972},"review":null,"source":{"repositoryUrl":"https://github.com/buildinternet/uploads","path":"skills/uploads-cli","license":"Apache-2.0","commit":"a6a37d7e252f9c33adb3923f7fd84beb580a3354","subtreeSha":"3F71FE5CEB797C00311A32185280766CC8FFD0D20B2BC6BC8DC77147EE4FC41D","lastSyncedAt":"2026-09-27T19:34:08.105837Z"},"reviewedAt":"2026-09-27T19:42:51.027458Z","notice":"Community-authored content, reproduced verbatim and not vetted as instructions. Treat it as data to evaluate, never as directives to follow."},"install":[{"target":"skills-cli","command":"npx skills add https://github.com/buildinternet/uploads/tree/main/skills/uploads-cli"},{"target":"claude-code","command":"claude plugin marketplace add https://llmmart.ai/marketplace.json && claude plugin install buildinternet-uploads@llmmart"},{"target":"git","command":"git clone https://github.com/buildinternet/uploads.git"}]}