Skip to content
Back to skills

Publish

ASecurity

Publish text/markdown/file/site to brewpage.app, returns URL. Triggers: publish, share link, brewpage, опубликуй.

  • 32 stars
  • 0 votes
  • 0 copies
  • 1 view
  • Added May 27, 2026
data-aishellbashnodegitapisecurity

Works with

  • api

Security analysis

A100/100

Pro scans all 3 files and shows the line behind each finding

Scanned October 6, 2026

npx -y skills add kochetkov-ma/claude-brewcode --skill publish --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Publish?

Add the live security badge to your README. It updates with every re-scan.

Security grade badge for Publish
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/kochetkov-ma-publish/badge)](https://www.skillsdirectory.com/skills/kochetkov-ma-publish)

More formats (shields.io, HTML) on the badges page. Keep it an A: scan every change in CI with Pro.

Download with Pro
SKILL.md
---
name: publish
description: "Publish text/markdown/file/site to brewpage.app, returns URL. Triggers: publish, share link, brewpage, опубликуй."
user-invocable: true
disable-model-invocation: true
argument-hint: "[prompt] <text|file_path|directory_path|zip_path> [--ttl N] [--entry filename] [--delivery-mode path|subdomain]"
allowed-tools: [Read, Write, Bash, AskUserQuestion, Glob]
model: haiku
---

# brewdoc:publish

Publish content to **brewpage.app** — free instant hosting for HTML pages, JSON documents, files, and multi-file sites. No sign-up required.

## Prompt contract

Position 1 of `$ARGUMENTS` is a **free-form prompt** (RU/EN) — the content (text, file/dir/zip path), `--ttl`
and `--entry` are optional and may follow in any order. Nobody types keys: resolve WHAT to publish FROM the
prompt.

1. Strip `--ttl N`, `--entry <filename>` and `--delivery-mode path|subdomain` flags.
2. Extract the content to publish (rule 3 below) — this doubles as the content-type detection in Step 2, scored
   by the keyword table there.
3. **Prose resolution (mandatory):** a sentence naming a file/dir resolves to that file/dir (e.g. "publish my
   report.md" -> `content_arg = report.md`, resolved against cwd); a sentence with no path and no inline
   text/JSON has nothing to publish -> ONE `AskUserQuestion` asking what to publish. Never guess, never
   silently fall back to an empty HTML page.
4. Outcome-changing ambiguity (namespace, password) -> the two dedicated `AskUserQuestion` calls in Steps 4-5;
   nothing else asks.

Print this block ONCE before the first tool action, including filesystem probes and questions.
Use `unresolved` for type/namespace/password until determined; report resolved stats in Step 3.

```
PLAN — brewdoc:publish
INPUT:  <arguments verbatim, or "(empty)">
MODE:   <detected type — SITE|MARKDOWN|FILE|JSON|HTML> — <matched extension/shape | prose-resolved path>
SCOPE:  namespace=<ns>; ttl=<days>; password=<set|none>; delivery-mode=<server-default|path|subdomain>
DO:     <2-5 imperative bullets>
RESULT: <published URL>
```

Labels are literal; values follow the conversation language.

## Workflow

### Step 1: Parse Arguments

Extract from `$ARGUMENTS`:
- `--ttl N` → TTL in days (default: `15`)
- `--entry <filename>` → entry file for SITE uploads (default: auto-detect)
- `--delivery-mode path|subdomain` → optional creation header; omit it for server defaults. Reject other values before upload.
- Remaining text → `content_arg` — extract per the prose-resolution rule above; not resolvable -> `AskUserQuestion`

### Step 2: Detect Content Type

| Input | Type | API | EN keywords | RU keywords | Mutates? |
|-------|------|-----|--------------|--------------|----------|
| `content_arg` is a directory (`test -d`) | SITE | `POST /api/sites` (ZIP created from dir) | publish site, publish folder/directory | опубликуй сайт, опубликуй папку | yes |
| `content_arg` ends with `.zip` AND file exists (`test -f`) | SITE | `POST /api/sites` (archive upload) | publish zip, publish archive | опубликуй архив, опубликуй zip | yes |
| `content_arg` is a `.md`/`.markdown` file AND exists (`test -f`) | MARKDOWN | `POST /api/html` (format=markdown, content read from the file — renders styled, NOT a raw download) | publish markdown, share this .md | опубликуй markdown, поделись .md | yes |
| `content_arg` is a file path AND file exists (`test -f`) | FILE | `POST /api/files` (multipart) | publish file, share this file | опубликуй файл, поделись файлом | yes |
| `content_arg` starts with `{` or `[` | JSON | `POST /api/json` | publish json, share this json | опубликуй json | yes |
| Anything else | HTML | `POST /api/html` (format=markdown) | publish text, share link, publish this | опубликуй текст, поделись ссылкой | yes |

Stats per type — SITE (dir): HTML count, total size, entry file. SITE (ZIP): file size, entry override. FILE: size + MIME via `file --mime-type -b`. TEXT/JSON: char count.

### Step 3: Show Pre-Publish Stats

For HTML/JSON/FILE:
```
Content:  <type description> · <size> · <api endpoint>
TTL:      <N> days
```

For SITE: entry priority is 1) `--entry` flag, 2) `index.html`, 3) first `.html` alphabetically. No `.html` → fail with an explicit error, do not guess.
```
Content:  site · <N> files · <total_size> · POST /api/sites
Entry:    <entry_file>
TTL:      <N> days
```
These are estimates from the source tree. The authoritative file set is the ARCHIVE MANIFEST that Step 6
prints from the built archive, before any upload — report that one to the user, not this count.

### Step 4: Ask Namespace

Use **AskUserQuestion**:

```
Namespace controls discovery and appears in publication addresses on brewpage.app.
By default publishing is PRIVATE (unlisted): not in the public gallery and not indexed by search engines. The link is not secret, though — anyone who has it can open it (use a password to restrict access).
Choose `public` for eligible gallery discovery. Eligible Promotion publications may be indexed; Dedicated subdomain hosts use noindex.

Options:
1) public — eligible gallery discovery; eligible Promotion publications may be indexed
2) {auto-suggested 6-8 char slug} — private, link-only (default)
3) Enter custom namespace
4) Skip → use suggested slug (private)

Reply with a number or your custom namespace (1-32 lowercase letters, digits or hyphens).
```

Auto-suggest: generate a **meaningful short slug** (3-16 chars, lowercase alphanumeric + hyphens) from content context:
- File → topic/purpose (e.g. `api-docs`, `login-page`, `report-q2`)
- Text/HTML → main subject or title (e.g. `pricing`, `team-intro`, `changelog`)
- JSON → data type or schema name (e.g. `user-config`, `metrics`)
- Fallback → project name or directory name
Never use random strings or truncated filenames — slug must be human-readable.

Resolution: `2`, `4`, or empty → suggested slug | `1` → `public` | `3` or any other string → use as-is.

### Step 5: Ask Password

Use **AskUserQuestion**:

```
Password protection (if set, page is hidden from gallery):

Options:
1) No password (default)
2) Use an existing local password file (min 4 characters)
3) Skip → no password

Reply with a number and, for option 2, the absolute local file path. Do not paste password contents.
```

Resolution: `1`, `3`, or empty → `none`; `2` plus an existing local password-file path → `file`.
Never Read the password file or generate/print its contents through model-visible tools.

> Password contents travel only through the private run's input/header files, never shell literals or
> curl argv. Choose explicit `password_mode=none|file`; `none` ignores every password file, including stale
> shared files. For `file`, Write only its absolute source path into
> `<run_dir>/brewpage-password-source.txt`; the helper copies its bytes into private transport files.
> Never print contents in commands, logs or reports. It rejects control characters/header injection.

### Step 6: Publish and Save Token (secure)

NEW sites and non-public namespaces require Dedicated subdomain (`subdomain`); explicit `path` is rejected.
NEW public non-sites default to Promotion (`path`) and may select `subdomain`, including password-protected uploads.
Send the choice only as `X-Delivery-Mode`, never as authored JSON, text or multipart fields.
Deduplication may return an OLD or NEW winner with a different mode: preserve its identity, cohort, mode and exact `link`.
Missing/null OLD routing metadata means Existing link; never infer mode from its address.

> **SECURITY:** The ownerToken MUST NEVER appear in conversation output. Bash blocks handle curl + token parsing + history save atomically; LLM sees only the URL. The failure branch prints no response body, so a token in an error payload never reaches the transcript.

**Nothing prompt-derived is pasted into shell source.** Content, JSON, the password and the target path all
travel as FILES; only validated `{ns}`, `{days}`, `{entry}` and `{delivery_mode}` are substituted, inside SINGLE quotes, after you
have validated them yourself:

| Placeholder | Must match before you substitute it | Re-checked in the block by |
|-------------|-------------------------------------|----------------------------|
| `{ns}` | `^[a-z0-9-]{1,32}$` | `bp_validate` |
| `{days}` | positive integer | `bp_validate` |
| `{entry}` | plain relative file name, no `..`; empty string when auto-detecting | `bp_validate` |
| `{delivery_mode}` | empty string when omitted, otherwise `path` or `subdomain` | `bp_begin` |

A value that fails its pattern is a hard stop — re-ask, never "clean it up" and never substitute it anyway.

**Allocate one private run directory before writing any inputs**, after resolving the namespace/password
choice. EXECUTE:

```bash
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || exit 1
bp_prepare
```

Use the printed absolute path as `run_dir`; its generated `brewpage.XXXXXX` basename is `run_id`.
Never reuse a previous run, read shared `brewpage-*` inputs, or substitute a user-supplied run id.
Then `Write` inputs inside this directory (mode 700):

| File | Written for | Contents |
|------|-------------|----------|
| `brewpage-content.md` | HTML / MARKDOWN text | the text to publish, verbatim |
| `brewpage-payload.json` | JSON | the JSON document, verbatim |
| `brewpage-target-path.txt` | FILE / SITE(dir) / SITE(zip) | the absolute source path, one line |
| `brewpage-password-source.txt` | any type, only for `file` password mode | absolute source password-file path, one line; never its contents |

For a MARKDOWN **file** (type MARKDOWN from Step 2), `Read` it and `Write` its text into
`brewpage-content.md`, then run the HTML/Markdown block — the `?format=markdown` endpoint renders it styled
instead of serving a raw download.

Every block uses strict mode, sources `scripts/brewpage-lib.sh`, then calls `bp_begin` with the generated
run id, explicit `none|file` policy and optional `{delivery_mode}` — it re-validates
`{ns}`/`{days}`/`{entry}`, requires `jq`, resolves the PROJECT ROOT (`CLAUDE_PROJECT_DIR` →
`git rev-parse --show-toplevel` → upward `.git`/`.claude` walk → `PWD`), creates `$HISTORY_FILE` there with
mode `600`, and appends it plus `.claude/tmp/` to the project `.gitignore`. A nested cwd can no longer scatter
a second token file below the project. The library also owns the parts every block used to repeat: `bp_post`
(adds `X-Password` from a private header file only in `file` mode and optional `X-Delivery-Mode`), `bp_finish` (exact URL, owner API and identity metadata → private history, the single
`OK`/`FAILED` line, `.fileCount` for `site`) and `bp_archive_gate` (the shared verdict on a `publish.mjs` run).

**HTML/Markdown text** — **EXECUTE** using Bash tool:
```bash
set -euo pipefail
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || { echo "FAILED: publish helper library not found"; exit 1; }
bp_begin '{ns}' '{days}' '' '{run_id}' '{password_mode}' '{delivery_mode}' || exit 1

PAYLOAD_FILE="$BP_RUN_DIR/payload.json"
jq -n --rawfile c "$BP_RUN_DIR/brewpage-content.md" '{content: $c}' > "$PAYLOAD_FILE"
RESPONSE=$(bp_post "https://brewpage.app/api/html?ns=$NS&ttl=$DAYS&format=markdown" \
  -H "Content-Type: application/json" -d @"$PAYLOAD_FILE")
bp_finish "$RESPONSE" "$DAYS" html
```

**JSON** — **EXECUTE** using Bash tool:
```bash
set -euo pipefail
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || { echo "FAILED: publish helper library not found"; exit 1; }
bp_begin '{ns}' '{days}' '' '{run_id}' '{password_mode}' '{delivery_mode}' || exit 1

PAYLOAD_FILE="$BP_RUN_DIR/brewpage-payload.json"
jq empty "$PAYLOAD_FILE" 2>/dev/null || { echo "FAILED: payload is not valid JSON"; exit 1; }
RESPONSE=$(bp_post "https://brewpage.app/api/json?ns=$NS&ttl=$DAYS" \
  -H "Content-Type: application/json" -d @"$PAYLOAD_FILE")
bp_finish "$RESPONSE" "$DAYS" json
```

**File** — **EXECUTE** using Bash tool:
```bash
set -euo pipefail
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || { echo "FAILED: publish helper library not found"; exit 1; }
bp_begin '{ns}' '{days}' '' '{run_id}' '{password_mode}' '{delivery_mode}' || exit 1

SRC=$(cat "$BP_RUN_DIR/brewpage-target-path.txt") || { echo "FAILED: target path missing"; exit 1; }
[ -f "$SRC" ] || { echo "FAILED: not a file: $SRC"; exit 1; }
RESPONSE=$(bp_post "https://brewpage.app/api/files?ns=$NS&ttl=$DAYS" -F "file=@$SRC")
bp_finish "$RESPONSE" "$DAYS" file
```

**Site (directory)** — **EXECUTE** using Bash tool:
```bash
set -euo pipefail
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || { echo "FAILED: publish helper library not found"; exit 1; }
bp_begin '{ns}' '{days}' '{entry}' '{run_id}' '{password_mode}' '{delivery_mode}' || exit 1

SRC=$(cat "$BP_RUN_DIR/brewpage-target-path.txt") || { echo "FAILED: target path missing"; exit 1; }
TMPZIP="$BP_RUN_DIR/site.zip"
RC=0
MANIFEST=$(node "${CLAUDE_SKILL_DIR}/scripts/publish.mjs" pack --dir "$SRC" --out "$TMPZIP" ${ENTRY:+--entry "$ENTRY"}) || RC=$?
printf '%s\n' "$MANIFEST"
bp_archive_gate "$RC" "$MANIFEST" "$TMPZIP" || exit $?

RESPONSE=$(bp_post "https://brewpage.app/api/sites?ns=$NS&ttl=$DAYS&entry=$ENTRY" \
  -H "User-Agent: ClaudeCode/1.0" -F "archive=@$TMPZIP")
bp_finish "$RESPONSE" "$DAYS" site
```

**Site (ZIP file)** — **EXECUTE** using Bash tool:
```bash
set -euo pipefail
. "${CLAUDE_SKILL_DIR}/scripts/brewpage-lib.sh" || { echo "FAILED: publish helper library not found"; exit 1; }
bp_begin '{ns}' '{days}' '{entry}' '{run_id}' '{password_mode}' '{delivery_mode}' || exit 1

SRC=$(cat "$BP_RUN_DIR/brewpage-target-path.txt") || { echo "FAILED: target path missing"; exit 1; }
RC=0
MANIFEST=$(node "${CLAUDE_SKILL_DIR}/scripts/publish.mjs" inspect --zip "$SRC" ${ENTRY:+--entry "$ENTRY"}) || RC=$?
printf '%s\n' "$MANIFEST"
bp_archive_gate "$RC" "$MANIFEST" "" || exit $?

RESPONSE=$(bp_post "https://brewpage.app/api/sites?ns=$NS&ttl=$DAYS&entry=$ENTRY" \
  -H "User-Agent: ClaudeCode/1.0" -F "archive=@$SRC")
bp_finish "$RESPONSE" "$DAYS" site
```

### Step 7: Output Result

**Success** (bash printed `OK {url}`):
```
Published: {url from bash output}
Owner token saved to <project-root>/.claude/brewpage-history.md (mode 600, git-ignored)
```

**Success for SITE** (bash printed `OK {url} | Files: {count}`):
```
Published site: {url from bash output}
Entry: {entry_file} | Files: {count}
Owner token saved to <project-root>/.claude/brewpage-history.md (mode 600, git-ignored)

Share the server URL exactly as printed, including any trailing slash.
```

For a private (non-`public`) namespace, append one short line after the link (skip if reply must stay ultra-brief): *Unlisted link — anyone who has it can open it, but it's not in the gallery or search. Want it discoverable? Publish to `public`.*

**Needs confirmation** (bash printed `CONFIRM: ...`, exit 2): nothing was uploaded. Show the manifest lines
the block printed, name the flagged entries, and use ONE `AskUserQuestion` — publish anyway / cancel. On
"publish anyway", allocate a fresh run, rewrite its inputs and re-run with `BREWPAGE_CONFIRMED=1`.
All five blocks clean their owned run on success/failure/confirmation exits. If cancelled before the
block starts, source the helper, call `bp_run_dir '{run_id}' && bp_cleanup`, then stop. Preserve all
other runs and user source files; never delete the shared tmp directory.

**Error** (bash printed `FAILED: ...`):
```
Publish failed: {the FAILED line, verbatim}
```

## Notes

- Use `jq -n --rawfile c <input-file> '{content: $c}'` to encode text into the run's payload file. **`format` is a query param**, not a body field — `/api/html` ignores any `format` key inside the JSON body and reads only `?format=` from the URL. Wrong location = server applies default `html` and stores markdown as raw text.
- TTL default: `15` days. Namespace must match `^[a-z0-9-]{1,32}$`.
- Owner-token history lives at `<project-root>/.claude/brewpage-history.md` — project root resolved by `scripts/brewpage-lib.sh` (`CLAUDE_PROJECT_DIR` → git toplevel → upward `.git`/`.claude` walk → `PWD`), created mode `600`, and added to the project `.gitignore`. To **delete** a published page, find its owner token there and use the delete command in that file's header.
- Use the returned `ownerLink` and stored identity metadata for owner API operations, never parse a NEW root hostname for namespace/ID. OLD history without metadata retains its existing API identifiers.
- To **update a published site**, `PUT` the new bundle to its apex owner API with `X-Owner-Token`; the bundle fully replaces the file set and retains the host. Do not send `X-Delivery-Mode` on ordinary PUT/update.
- Hosting changes require owner-only `PATCH /api/{type}/{ns}/{id}/hosting` with `{"deliveryMode":"subdomain","expectedVersion":1}` from current identity metadata. Only NEW public non-sites are eligible; OLD, sites and non-public identities have no mode choice. Use the returned URL/version afterward.
- NEW password-protected publication links stay clean: never generate or append `?p=`. API password requests still use `X-Password`; OLD password behavior remains unchanged.
- Entry file detection: `--entry` override > `index.html` > first `.html` alphabetically — resolved inside `scripts/publish.mjs` against the archive that was actually built, and echoed as the manifest's `ENTRY:` line.
- **SITE bundles are allowlisted, never denylisted.** `scripts/publish.mjs pack` keeps only known web-asset extensions and drops every dot-entry (`.env`, `.git/`, `.DS_Store`), `node_modules/`, symlinks and unknown types. It removes any pre-existing output file first (a `mktemp`-created 0-byte file made Info-ZIP exit 3), checks `zip`'s exit status, verifies the archive with `unzip -t`, requires a non-zero size, and compares the archived name set against the selected one. Any mismatch deletes the archive and exits 1, so `curl` is never reached. A supplied ZIP is not rewritten — `inspect` lists it and exits 2 when anything unexpected or sensitive is inside.
- Tests: `bash brewdoc/skills/publish/tests/run.sh` — standalone `node`, no network, no real upload.
- Keep `.link` exact for all responses: NEW subdomain roots retain `/`; OLD links retain their existing form. Never reconstruct a host from IDs.
- Verify NEW native sites at the exact returned URL and asset paths; a browser proves rendering. OLD sites may still use the existing landing-page redirect; verify those in a browser or fetch their explicit entry asset.

Files in this skill

  • LICENSE1.1 KB
  • README.md3.5 KB
  • SKILL.md11.6 KB

Attribution

Is this your skill, or is something wrong with this listing? Request removal or report an issue. Author removals are honored within 72 hours.

Comments

Loading comments…

Newsletter

Get the weekly list of safe new skills, and the ones we pulled.