---
name: dropper-cli
description: >-
  Publish HTML pages, Markdown documents and built static sites to Dropper with the `dropper` CLI
  and hand back a shareable URL — a report, plan or write-up, test or coverage output, a preview of
  a built front end, an HTML prototype. Use it for "publish this", "give me a link", "share the
  report", "host this page", "drop it", "deploy a preview", "put it somewhere I can open on my
  phone", and to list, label, update, rename, password-protect or delete drops that already exist.
  Works headless — sandboxes, containers, CI — with an API key. Not for anything that needs a
  server, a database or secrets at runtime: a drop is static files only.
---

# Dropper CLI

Dropper serves static files at `<server>/d/<handle>/<slug>/`. `dropper drop` uploads a file or a
directory and prints that URL; the URL is the deliverable, so hand it back on its own line rather
than pasting the content into the reply. This copy of the skill came from `https://dropp.sh`,
which is the server unless `DROPPER_URL` names another.

## Setup

```bash
command -v dropper >/dev/null || curl -fsSL https://dropp.sh/install.sh | sh
dropper whoami    # exit 0 = ready; otherwise one line on stderr says what is missing
```

The installer puts a checksum-verified binary in `~/.local/bin` without sudo, on macOS and glibc
Linux (x64, arm64); add that directory to `PATH` if `dropper` is still not found. Alpine/musl and
Windows build from source (`https://dropp.sh/docs/cli`); macOS can also
`brew install --cask dtolb/tap/dropper`.

Credentials, first match wins:

1. `DROPPER_TOKEN`, a personal API key minted at `https://dropp.sh/keys`, with
   `DROPPER_URL=https://dropp.sh`. This is the way for sandboxes, CI and any machine without a
   browser.
2. `dropper login https://dropp.sh`, a browser sign-in for a person on their own computer,
   used only for the server it signed in to. The callback listens on `127.0.0.1`, so the browser
   must run on the same machine as the CLI: it cannot work in a remote sandbox. Ask the user for
   an API key instead.
3. `"token"` in `~/.config/dropper/config.json`.

Refer to the key as `$DROPPER_TOKEN` and never write its value into a command, a file, a log or a
drop. Transcripts and drops outlive the session. `dropper whoami` proves the key works without
showing it.

| `whoami` says | Meaning and fix |
|---|---|
| `not logged in — run 'dropper login …' … or use an API key` | Nothing configured: set `DROPPER_URL` and `DROPPER_TOKEN` |
| `not logged in — <server> rejected the API key from <source>` | Revoked or mistyped key: ask the user for a new one |
| `not logged in — the login for <server> has expired or was revoked` or `… rejected the stored login` | A person has to run `dropper login` again; in a sandbox, ask for an API key |
| `could not reach <server> (<code>)` | Network: a sandbox egress allowlist needs the server's host |
| `<server>: account suspended …` | Stop and tell the user; no retry helps |
| `<server> has no /api/me …` | The server is older than this CLI; everything but `whoami` still works |

## Publish

```bash
dropper drop report.md --slug q3-report --name "Q3 report" --label acme
dropper drop ./dist --slug pr-142-preview --name "PR 142 preview" --label acme/site
dropper drop prototype.html --gen-password --label acme
```

Output contract: stdout line 1 is the URL, and with `--gen-password` line 2 is
`password: <four-word-passphrase>`. Any failure exits 1 with one line on stderr, usually the
server's own message.

| Flag | Effect |
|---|---|
| `--slug <slug>` | `^[a-z0-9][a-z0-9-]{0,63}$`. Omitted, the server picks 8 random characters. Choose a stable, descriptive slug so later runs can update the same URL |
| `--name <name>` | Display name, and the title of a rendered Markdown page (a trailing `.md` is dropped). Defaults to the file name, and to `<dir>.zip` for a directory, so pass it for a directory drop |
| `--password <code>` / `--gen-password` | Viewers must enter the code first. The generated one is printed after the URL |
| `--public` | Lists the drop in the server's gallery for every signed-in user. Without it the drop is unlisted, not secret: anyone holding the URL can open it |
| `--label <path>` | The project the drop belongs to (see Labels). Repeatable, at most 20 |
| `--description <text>` | A short public summary, at most 200 characters, shown under the name and on the gallery |

`drop` takes an `.html`/`.htm` file, a `.md` file, or a non-empty directory, which it zips on the
fly. It refuses a `.zip`, a PDF or an image on its own: unzip it, or put the files in a directory
with an `index.html`.

## Labels

Label every drop with its project path: `--label <repo>` for the repository or project the work
belongs to, `--label <repo>/<area>` when it has distinct parts (`acme/site`, `acme/docs`). A path
is up to three names joined by `/`; a name is at most 32 lowercase letters, digits and `-`, and
starts with a letter or digit. Nothing converts a name for you: write the repo `Flight_Sandbox` as
`flight-sandbox` yourself. A malformed name fails with
`invalid label path "<path>": names use a-z, 0-9 and '-', joined with '/'`, and a fourth level
with `labels nest at most 3 levels: <path>`. A missing label is
created on the spot, so run `dropper labels ls` first and reuse the path a project already has
instead of coining a near-duplicate. A sublabel counts under its parents:
`dropper list --label acme` lists the `acme/site` drops too.

```bash
dropper labels ls                     # acme         12 drops  blue
                                      #   acme/site   3 drops  blue      (or: no labels)
dropper labels rename acme/site web   # renamed acme/site to acme/web
dropper labels move acme/web /        # moved acme/web to web            ("/" is the top level)
dropper labels color acme green       # recoloured acme to green         (top-level labels only)
dropper labels rm acme/docs           # deleted acme/docs; its drops and sublabels moved to acme
```

Removing a top-level label prints
`deleted <path>; removed from its drops, its sublabels moved to the top level`, and a sublabel
that lands beside a label of the same name merges into it. A path that names no label fails with
`no label: <path>`. Colours: blue, green, purple, orange, cyan, red, gold, magenta, lime, grey.

## Directories and built sites

- The CLI uploads every file under the directory, dotfiles included: `.git/`, `.env`,
  `node_modules/`. Drop the build output (`dist/`, `build/`, `out/`), never a repository root,
  and check it for secrets first. Symlinks are skipped without a warning, so copy linked files in.
- The entry page is `index.html`, else `index.md`, else `README.md`, at the root and in each
  subdirectory (`docs/` serves `docs/index.html`). A directory with none of them uploads, but its
  URL is a 404.
- A drop lives under a sub-path, so a root-absolute URL (`/assets/app.js`, `href="/about"`) hits
  the server root and 404s. Use relative URLs, or build with the base set: Vite `--base ./`, or
  the framework's base-path option set to `/d/<handle>/<slug>/` (the handle is in
  `dropper whoami --json`).
- There is no SPA fallback: an unknown path is a 404, so a client-side router needs hash routes or
  one real file per route.
- The content type comes from the extension: html, css, js, mjs, json, map, svg, png, jpg, jpeg,
  gif, webp, avif, ico, txt, md, xml, pdf, wasm, woff, woff2, ttf. Anything else, video and audio
  included, is `application/octet-stream`: a direct link downloads it.
- Nothing runs server-side: no API routes, no environment variables. Inline CSS and JS, `data:`
  URIs and CDN scripts all work.
- Limits: 50 MB zipped upload, 200 MB extracted, 2,000 files; `meta.json` at the root is reserved.

## Markdown

A `.md` renders as a styled page for browsers: an "On this page" outline, GFM tables, `- [ ]`
task lists, highlighted code, and `mermaid` code fences drawn client-side. The page title is
`--name`, never the first heading. Raw HTML inside Markdown shows as literal text, so write an
`.html` file when the page needs markup. A `.md` inside a directory drop renders the same way at
its own path. `curl <url>` gets the Markdown source back, `<url>?raw=1` forces the source in a
browser, and a file over 1 MB is always served as source.

## Update, rename, protect

`dropper edit <slug> [flags]` changes one of your drops and prints its URL; flags combine.

| Flag | Effect |
|---|---|
| `--file <path>` | Replaces the whole content. The URL, name, description, labels, password and visibility stay |
| `--name <name>` | New display name |
| `--description <text>` / `--clear-description` | Set or remove the summary; a blank `--description` is refused |
| `--label <path>` / `--clear-labels` | `--label` (repeatable) replaces the drop's whole label set; `--clear-labels` removes them all |
| `--password <code>` / `--gen-password` / `--clear-password` | Set, generate or remove the access code (one of the three) |
| `--public` / `--private` | Add to or remove from the gallery; omit both to leave it |
| `--slug <new>` | Rename. The old URL 404s at once, breaking every link already shared |

A viewer's browser may keep showing the previous version for up to 60 seconds; a reload fixes it.

Create-or-replace, for a drop this task owns:

```bash
if ! out=$(dropper drop ./dist --slug pr-142-preview --name "PR 142 preview" --label acme/site 2>&1); then
  case "$out" in
    "slug already exists"*) out=$(dropper edit pr-142-preview --file ./dist) || exit 1 ;;
    *) printf '%s\n' "$out" >&2; exit 1 ;;
  esac
fi
printf '%s\n' "$out"
```

`slug already exists` only says one of your drops has that slug. Replace it when it is the drop
you are maintaining (this PR's preview, this report's earlier run); otherwise pick another slug.
`dropper list` shows what exists.

## Inspect and verify

- `dropper list [--label <path>]` prints one line per drop: slug, kind, `locked`/`public`
  markers, file count, creation time, URL, then its label paths comma-joined when it has any;
  `no drops` when nothing matches. It has no JSON mode; for JSON,
  `curl -sS --fail-with-body -H "Authorization: Bearer $DROPPER_TOKEN" "$DROPPER_URL/api/drops"` returns
  `{"drops":[…]}` (add `?label=acme` to filter the same way).
- `dropper whoami --json` returns `server`, `source`, `handle`, `name`, `email`, `role` and
  `credential`.
- Verify a publish with `curl -sS -o /dev/null -w '%{http_code}\n' "$url"`: 200 is live. A
  protected drop answers 401 with its unlock page, which is also success.

## One file at a time

`edit --file` replaces everything. To add, replace or remove single files, such as one page of a
large site or an image, call the files API with an API key in `DROPPER_TOKEN`. A `dropper login`
token is not available to scripts, so when `whoami` shows `(dropper login)` as the source, ask the
user for a key first. `$slug` is one of your drops:

```bash
files="$DROPPER_URL/api/drops/$slug/files"
auth="Authorization: Bearer $DROPPER_TOKEN"
c() { curl -sS --fail-with-body -H "$auth" "$@"; }   # a failure prints the server's {"error":…}
c "$files"                                           # {"files":[…]}
c "$files/index.html"                                # read one back
c -T chart.png "$files/img/chart.png"                # write one, max 20 MB
c -X PUT -G --data-urlencode "from=https://example.com/photo.jpg" "$files/img/photo.jpg"
c -X DELETE "$files/old.html"
```

With `from=` the server downloads a public `https://` URL itself (no redirects, 15 s budget), so
the bytes never pass through the sandbox. Writes never create a drop (an unknown slug is a 404)
and never touch its name, description, labels, password or visibility; the entry page cannot be deleted. The rest of
the API, including view stats: `https://dropp.sh/docs/api` and
`https://dropp.sh/openapi.json`.

## Scope

Publishing to a Dropper server and managing your own drops from a shell. Not the dashboard, not
hosting anything with a server side. When the Dropper MCP tools are connected they do the same
job; this skill is for agents that have a shell instead.

## Working autonomously

Go ahead without asking: install the CLI, run `whoami`, `drop` under a new slug with its project
`--label`, `edit --file`, `--name`, `--description` or `--label` on a drop this task created,
`list`, `labels ls`, read files back, and per-file writes to your own drops. Ask first, or return
the command for the user to run, before `dropper rm` (the URL dies immediately; the drop sits in
the owner's trash for 30 days, restorable from the dashboard's Profile → Deleted drops),
`edit --slug` (it breaks shared links), `--public` when the user did not ask for the gallery,
`--clear-password`, `dropper labels rename`, `move` or `rm` (each re-files every drop under that
label), and replacing a drop this task did not create.

Never publish credentials, `.env` files, private keys or personal data. When a page has to carry
something sensitive, protect it with `--gen-password` and say so.

Done when the command exits 0 and the URL answers 200, or 401 for a protected drop. That is the
stop point: reply with the URL on its own line, the password when one was generated, and one line
on what the page is.

<examples>
<example>
"publish the coverage report so I can look at it"
→ `dropper drop ./coverage --slug coverage-report --name "Coverage report" --label <repo>` (the
directory has an `index.html`), check it answers 200, reply with the URL.
</example>
<example>
"deploy a preview of this branch's site"
→ build with a relative base (`vite build --base ./`), then the create-or-replace snippet with
`--slug <branch>-preview --label <repo>` on `dist/`, so each rerun updates the same URL.
</example>
<example>
"share the incident write-up, but it names customers"
→ `dropper drop incident.md --slug incident-2026-09-12 --gen-password --label <repo>`, then reply
with the URL and the password, and say the page is protected because of what it contains.
</example>
</examples>

<tone_preference>
The reply is the URL plus at most two lines. No preamble, no tour of the page's sections.
</tone_preference>
