Skip to content
Back to skills

Doc Preview

ASecurity

Render a Markdown document to GitHub-styled HTML and host it on this machine's Tailscale tailnet URL so the user can read it in a browser before committing. Use after writing or substantially editing a Markdown doc the user may want to preview — a guide, README, design doc, runbook, report, or research writeup — especially when they ask to "preview", "share", "host", or "see" a doc. Multi-session safe: shares append to one fixed URL. Personal to this machine's tailnet (the host is derived fro...

  • 2 stars
  • 0 votes
  • 0 copies
  • 3 views
  • Added September 19, 2026
ai-agentsgobashnodegitapi

Works with

  • terminal
  • cli
  • api

Security analysis

A92/100
  • mediumInstalls packages at runtime which could introduce malicious dependencies

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

Scanned October 4, 2026

npx -y skills add verkyyi/claude-fleet --skill doc-preview --agent claude-code

Installs into .claude/skills of the current project.

Are you the author of Doc Preview?

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

Security grade badge for Doc Preview
[![Security: A — Skills Directory](https://www.skillsdirectory.com/api/skills/verkyyi-doc-preview/badge)](https://www.skillsdirectory.com/skills/verkyyi-doc-preview)

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: doc-preview
description: Render a Markdown document to GitHub-styled HTML and host it on this machine's Tailscale tailnet URL so the user can read it in a browser before committing. Use after writing or substantially editing a Markdown doc the user may want to preview — a guide, README, design doc, runbook, report, or research writeup — especially when they ask to "preview", "share", "host", or "see" a doc. Multi-session safe: shares append to one fixed URL. Personal to this machine's tailnet (the host is derived from tailscale at runtime); with no tailnet, `--tunnel` serves it on a public cloudflared quick-tunnel URL instead.
---

# doc-preview — host Markdown docs on a fixed tailnet URL

<!-- fleet skill -->

Use this whenever you have written or meaningfully edited a Markdown document and the user
would benefit from reading the rendered result (not raw `.md`) in a browser before commit.

**In a fleet session this is THE way to host anything for the operator — never the
Artifact tool.** An Artifact page belongs to the claude.ai account that published it, and
the fleet rotates subscription accounts under sessions, so the operator cannot know which
login would show it (hook-enforced: `hooks/artifact-guard.py` refuses a publish and points
here; issue #526). This URL is on the tailnet: any device, no login, every session's shares
in one list. **`.html` files are accepted too** and served as-is — a dashboard, a report
page, an interactive mockup — titled from their `<title>`; Markdown gets the GitHub-styled
viewer. Anything you would have published as an Artifact: write the file, share it here,
relay the `READY` URL. A local image an `.html` page references by a **relative**
`<img src>` is copied beside the served page (as a Markdown `![]()` image already was), so
a report can show screenshots from a sibling `evidence/` folder — the EPIC report's
before/after evidence (issue #810) rides along to the tailnet URL; remote, `data:` and
site-absolute refs are left alone.

## How it works

`share.sh` renders the given Markdown file(s) into GitHub-styled HTML (client-side `marked`
+ `github-markdown-css`, so **no npm install is needed**), serves them from one shared
directory via a loopback `server.py` (a `http.server` + a tiny control API), and fronts it
with `tailscale serve` (HTTPS on the tailnet). The viewing browser needs internet for the CDN libs.

**Three serving modes** (recorded in `~/.cache/claude-doc-preview/mode`, issues #1093/#1151):

- **`https`** — the default: `server.py` on loopback, fronted by `tailscale serve`. URL
  `https://<magicdns>[:<port>]/`.
- **`http-direct`** — the fallback when this login is **not tailscale's operator** (a machine
  has exactly one; `tailscale debug prefs` → `OperatorUser`) and has no root, so `tailscale
  serve` is refused. `server.py` binds the machine's tailscale IPv4 instead and the URL is
  `http://<magicdns>:<port>/` — plain http, but reachable only inside the tailnet, whose link
  is WireGuard-encrypted. The `READY` line says so. Sticky until `--stop`. Public (Funnel)
  links need serve rights, so `--publish` refuses in this mode. `fleet-doctor`'s `docprev`
  row says which mode this login gets, and the one-time `sudo tailscale serve …` command to
  get HTTPS instead (then `--stop` and re-share — `share.sh` adopts an existing route to its
  loopback port). Don't move the operator: it would break the login that holds it.
- **`tunnel`** — **no tailnet needed** (issue #1151). Opt-in only: `share.sh --tunnel <file>`
  or `DOC_PREVIEW_MODE=tunnel`. `server.py` runs on loopback in its `public` mode and a
  `cloudflared` quick tunnel (no account, no config; `brew install cloudflared`) fronts it at
  a random `https://<words>.trycloudflare.com/`. **Everything shared is PUBLIC** to anyone
  holding that link — the index too — so the server strips each doc's header (session,
  date, source path), drops source paths from the index, and 404s `/_ctl/*` (no toggle:
  `--publish`/`--pubstatus` just report the tunnel URL, `--unpublish` refuses — `--remove`
  the doc instead). Sticky until `--stop`; `--tunnel` refuses while a tailnet share is live
  (it would make every session's docs public). The hostname changes whenever cloudflared
  restarts (reboot, `--stop`), so earlier links die with it. Use it only when the user
  wants a link without the tailnet — it is public-by-obscurity, not private.
- **`local`** — **no tailnet, nothing public** (issue #1379). Opt-in: `share.sh --local
  <file>`. `server.py` on `127.0.0.1` only; `READY http://127.0.0.1:<port>/d/<id>/` — a URL
  that works on THIS machine alone, made to be handed to `bin/fleet-open.sh`, which opens it
  in the operator's own browser over their ssh (the `fleet-open` skill). `share.sh --open
  [--local] <file>` does both and prints `OPEN <fleet-open's result>`. A later share without
  `--local` turns the same server into `https`; `--local` beside an `https`/`tunnel` share
  reuses their loopback server and only prints the loopback URL; beside `http-direct` (server
  on the tailnet IP) it refuses.

The local port is picked by a real `bind()` probe (not `lsof`, which can't see another
login's server), and a server that fails to start leaves no `server.pid`/`server.port` and
no half-added entries behind.

**Multi-session safe — this is the key property.** Every share **appends** to one shared
collection behind a **single fixed URL**. A new share never removes other docs and never
changes the URL. The root page (`/`) lists everything currently shared, across all sessions
(each row shows the title, source path, session label, and time). So multiple concurrent
Claude sessions can each share docs and they all show up in one list.

## Usage

Add doc(s) to the shared list (paths relative to the repo or absolute):

```bash
~/.claude/skills/doc-preview/share.sh doc/开发与运维指南.md README.md
```

Output and what to relay:
- **Single doc shared** → prints `READY <direct doc URL>` + `INDEX <root list URL>`.
  **Relay the `READY` direct URL** (it opens the doc itself); mention the index only if useful.
- **Multiple docs shared** → prints `READY <root list URL>` + one `ADDED <direct URL>` per doc.
  **Relay the `READY` index URL** (it lists all of them).

In both cases the `READY` line is the primary URL to give the user.

Other commands:

```bash
~/.claude/skills/doc-preview/share.sh --open <file>       # share + open it in the operator's browser
~/.claude/skills/doc-preview/share.sh --local <file>      # loopback only: http://127.0.0.1:<port>/d/<id>/
~/.claude/skills/doc-preview/share.sh --list              # show what's currently shared
~/.claude/skills/doc-preview/share.sh --refresh           # re-render ALL shared docs in place
                                                          #  (same URLs; picks up source-file edits
                                                          #   and template changes — no new entries)
~/.claude/skills/doc-preview/share.sh --remove <substr>   # drop entries matching id/title/path
~/.claude/skills/doc-preview/share.sh --publish   <id>    # expose ONE doc on the public internet
~/.claude/skills/doc-preview/share.sh --unpublish <id>    # take that doc back off the public internet
~/.claude/skills/doc-preview/share.sh --pubstatus <id>    # is this doc public? print its URL
~/.claude/skills/doc-preview/share.sh --stop              # tear down EVERYTHING (all sessions)
```

## Public sharing (per-document, opt-in)

By default everything is **tailnet-only**. A single document can be exposed on the public
internet via a per-doc **Tailscale Funnel path mount** at `https://<host>:<funnelport>/p/<id>/`
(funnel port is 443/8443/10000 — here `:10000`, since nginx holds 443/8443).

**This is normally driven by an in-page toggle, not the CLI.** Each doc page rendered over the
tailnet shows a **"公开链接" switch** in its header. Flipping it on calls the loopback control
API (`/_ctl/publish`), which mounts just that one doc on Funnel and shows the public URL with a
copy button; flipping it off unmounts it. The switch is a thin wrapper over `share.sh --publish/
--unpublish`, so you can also drive it from the terminal.

Two properties make this safe to expose:
- **Only opted-in docs are public.** Funnel mounts individual `/p/<id>/` paths; every other
  doc and the collection index return 404 publicly. The tailnet URL still shows the full list.
- **The toggle is tailnet-only.** The control API (`/_ctl/*`) is never Funnel-mounted, so it is
  unreachable from the public internet (404). A public visitor can read the one doc — nothing else.
- **No internal metadata leaks.** The Funnel mount targets a `/_pub/<id>/` route that strips the
  page header server-side, so the public bytes contain only the document — no index link, no
  session/date, and no source file path (not even in view-source). The tailnet `/d/<id>/` view
  keeps the full header.

`--stop` turns off all public mounts too. Freshly published URLs take a few seconds to warm up
(Funnel provisions the route) — an immediate curl may return a connection error; retry.

Set `DOC_PREVIEW_SESSION=<label>` to tag your session in the list (default: hostname). When
you share on behalf of a distinct task/session, pass a short label so the user can tell rows apart.

## Notes for the agent

- Run `share.sh` via the Bash tool and surface the `READY` URL in your reply.
- **Prefer adding over resetting.** Just call `share.sh <files>` — it reuses the running
  server and existing tailnet route, so the URL stays fixed and other sessions' shares survive.
  Only use `--stop` when the user wants to tear down all sharing (it removes every session's docs).
- Re-sharing the same file adds a **new** entry (a fresh snapshot); it does not dedupe. If the
  user re-shares an updated doc and wants the old row gone, `--remove <substr>` the stale one.
  If the doc was edited in place and the user just wants the SAME URL updated, use `--refresh`.
- The doc page is reading/print-first by design: GitHub-styled, auto light/dark, print-friendly
  tables. The only interactive control is the header **"公开链接" public-link switch** (tailnet
  view only); don't add other UX chrome (filter/sort/theme toggles).
- A `READY http://…` URL with the `(http-direct: …)` note is a normal success — relay it as is.
- A `READY https://….trycloudflare.com/…` URL carries the `(tunnel: PUBLIC …)` note — relay it
  and say it is public. Never switch to `--tunnel` on your own when tailscale is down: the
  error names it; ask the user first, since it makes the shared docs public.
- On error, read the printed message (usually: tailscale logged out, HTTPS certs not enabled, or
  — for `--publish` — the Funnel node attribute not granted in the tailnet ACLs) and tell the fix.
- Override the static server's starting port with `DOC_PREVIEW_PORT=NNNN`.
- By default this serves over the **tailnet only** — only the user's tailnet devices can reach it.
  A doc goes public **only** when the user flips its "公开链接" switch (or you run `--publish` at
  their request); see "Public sharing" above. Don't publish a doc unless the user asks. The URL is
  a live preview (only while this machine is up and serve is active), not durable hosting.
- Note: port 443 (and 8443) on this machine's tailnet IP are held by a local nginx, so the
  skill auto-falls back to a free HTTPS port (e.g. `:8446`). That's expected.

Files in this skill

  • SKILL.md7.4 KB
  • render.mjs27.5 KB
  • server.py5.5 KB
  • share.sh11.3 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.