
Run Librariarr
- 1 installs
- 5 repo stars
- Updated August 4, 2026
- ahembree/librariarr
run-librariarr is a Claude Code skill that launches, smoke-tests, and screenshots the Librariarr Next.js webapp end to end.
About
run-librariarr is a Claude Code skill that brings up a clean Librariarr Next.js dev stack to an authenticated dashboard, then verifies behavior via curl smoke checks or headless-Chromium screenshots. Its driver.sh orchestrates docker up, admin setup, smoke tests against four authenticated endpoints, and screenshot capture. Developers use it to run the app, verify a change, or confirm the app boots cleanly.
- Launches a clean Librariarr Next.js dev stack, logs in an admin, and smoke-tests it end to end
- Runs authenticated curl smoke checks and headless-Chromium screenshots via a driver.sh script
- Uses Docker Compose plus the official Playwright image to screenshot dashboard pages
Run Librariarr by the numbers
- 1 all-time installs (skills.sh)
- Ranked #1,750 of 2,153 Testing & QA skills by installs in the Skillselion catalog
- Data as of Aug 5, 2026 (Skillselion catalog sync)
run-librariarr capabilities & compatibility
Free; requires Docker and a one-time ~2GB Playwright image pull.
- Capabilities
- app launch · smoke testing · screenshot capture · deployment verification
- Works with
- docker · playwright
- Use cases
- testing · devops
- Platforms
- macOS · Linux · WSL
What run-librariarr says it does
Drive a clean Librariarr dev stack to authenticated dashboard, then verify behaviour via curl smoke checks or headless-Chromium screenshots.
./.claude/skills/run-librariarr/driver.sh smoke # hit four authenticated endpoints, assert 200
npx skills add https://github.com/ahembree/librariarr --skill run-librariarrAdd your badge
Show developers this skill is listed on Skillselion. Paste this into your README.
| Installs | 1 |
|---|---|
| repo stars | ★ 5 |
| Last updated | August 4, 2026 |
| Repository | ahembree/librariarr ↗ |
What it does
Launch, smoke-test, and screenshot the Librariarr Next.js app to verify it boots and runs cleanly.
Who is it for?
Ad-hoc running, smoke-testing, and screenshotting the Librariarr Next.js app.
Skip if: The committed regression browser E2E suite, which lives in e2e/ instead.
When should I use this skill?
You need to run Librariarr, verify a change in the running app, or screenshot a dashboard page.
What you get
The Librariarr dev stack is up, admin-authenticated, and verified via smoke checks or screenshots.
- running dev stack
- smoke-test pass/fail on four endpoints
- PNG screenshots of UI pages
By the numbers
- smoke checks 4 authenticated endpoints
- driver.sh commands: up, setup, smoke, screenshot, clean, down, logs
Files
/run-librariarr
Drive a clean Librariarr dev stack to authenticated dashboard, then verify behaviour via curl smoke checks or headless-Chromium screenshots. The orchestration is driver.sh (in this directory); SKILL.md is its man page.
All paths below are relative to the repo root. driver.sh resolves the repo root from its own location, so you can invoke it via the absolute path from anywhere.
Prerequisites
- Docker + Compose v2 (
pnpm docker:dev:*is the project's standard dev flow). pnpm,curl,jq,nodeonPATH.- For screenshots only: just Docker — the official
mcr.microsoft.com/playwrightimage carries Chromium and its OS libs. Do not installlibnss3/libnspr4/libasound2on the host; the skill runs Playwright inside a container.
Run (agent path)
./.claude/skills/run-librariarr/driver.sh up # start docker dev stack, wait for /api/health 200
./.claude/skills/run-librariarr/driver.sh setup # create/login admin, save session cookie
./.claude/skills/run-librariarr/driver.sh smoke # hit four authenticated endpoints, assert 200setup is idempotent: on a fresh DB it POSTs /api/auth/setup (the same endpoint the onboarding page calls — [src/app/api/auth/setup/route.ts](src/app/api/auth/setup/route.ts)); if a user already exists (HTTP 403) it falls through to /api/auth/local/login. The session cookie lands at .claude/skills/run-librariarr/.cookies and is reused by smoke and screenshot.
Defaults — override via env:
| Env var | Default |
|---|---|
LIBRARIARR_BASE_URL | http://localhost:3000 |
LIBRARIARR_ADMIN_USER | admin |
LIBRARIARR_ADMIN_PASS | librariarr-dev-pw-1234 (8-char min per authSetupSchema) |
LIBRARIARR_APP_CONTAINER | librariarr-dev |
LIBRARIARR_PLAYWRIGHT_IMAGE | mcr.microsoft.com/playwright:v1.60.0-noble |
Run (human path)
pnpm docker:dev # foreground, Ctrl-C to stop
# then visit http://localhost:3000 — onboarding gate on first run, dashboard afterUseless headless because the onboarding page expects browser interaction. Use driver.sh setup instead.
Screenshot
./.claude/skills/run-librariarr/driver.sh screenshot /
./.claude/skills/run-librariarr/driver.sh screenshot /library/movies /tmp/movies.pngPNGs land in .claude/skills/run-librariarr/screenshots/<timestamp>.png by default. First run does a one-time pnpm install --ignore-workspace (Playwright JS, ~5MB) and a one-time docker pull of the Playwright image (~2GB). After that, screenshots take a few seconds each.
The screenshot container joins the dev stack's docker network and reaches the app by its compose service name (http://librariarr-dev:3000) — not localhost — so the same command works under WSL, Docker Desktop, and native Linux without --network=host tricks.
Formal browser E2E suite
This skill is for ad-hoc running/screenshots. The committed regression browser E2E suite lives in e2e/ (Playwright) and runs the same way conceptually — Postgres + the real production app image + the official Playwright image (browsers pre-baked, no download):
pnpm e2e:docker # docker-compose.e2e.yml: postgres + app + playwright, runs e2e/*.spec.ts
pnpm e2e:docker:down # tear down + wipe the ephemeral e2e DBIt uses a dedicated librariarr_e2e database and an in-suite first-run flow to create the admin (no manual setup step needed). See CLAUDE.md → "Browser E2E (Playwright)". Add new user-journey specs there, not here.
Reset between runs
./.claude/skills/run-librariarr/driver.sh clean # stops containers AND wipes DB volume
./.claude/skills/run-librariarr/driver.sh down # stops containers, keeps DB
./.claude/skills/run-librariarr/driver.sh logs # tail librariarr-dev container logsclean also deletes the saved session cookie since it would point at a now-gone user.
What smoke checks
GET, with the saved session cookie, asserting HTTP 200 on each. All four return non-empty JSON even on a totally fresh install (no media synced, no servers connected):
/api/auth/check-setup— public; confirmssetupRequired:false/api/system/info— authenticated; app version, DB size, migration status/api/servers— authenticated; returns{"servers":[]}until a Plex/Jellyfin/Emby server is linked/api/settings/auth— authenticated; auth method flags +localUsername
Pick these because they don't depend on any user-supplied state. Adding more (media routes, lifecycle, etc.) would require seeding.
Gotchas
- `pnpm install` in this skill dir silently no-ops without `--ignore-workspace`.
pnpm-workspace.yamllives at the repo root with global supply-chain config but nopackages:key. pnpm still walks up and treats the skill dir as part of the parent project, so a barepnpm installreports "Already up to date" against the wrong lockfile. The driver passes--ignore-workspacefor this exact reason. - The official Playwright Docker image ships browsers + OS libs but no Playwright JS package. That's intentional — you BYO the SDK version. The driver installs
playwrightinto the skill dir'snode_modulesand bind-mounts it into the container withNODE_PATH=/node_modules. - curl's Netscape cookie jar prefixes HttpOnly entries with `#HttpOnly_`. A naive
!line.startsWith("#")filter (used by most "parse Netscape cookie jar" snippets) drops the only cookie we care about.screenshot.mjsstrips the prefix before filtering. - Plex login is disabled on a local-only setup.
/api/auth/setupcreates the admin withplexLoginEnabled:false(src/app/api/auth/setup/route.ts:52) because no Plex account is linked yet. Don't expect the Plex OAuth button on the screenshot of/login. - DB volume survives `down`. A second
upreuses the existing admin user —setupwill hit the 403 path and log in instead of creating. Usecleanif you need a virgin DB (slower: re-pushes schema + reruns Prisma generate). - `/api/auth/setup` 403 on `setupRequired:false` is the success-case fallthrough, not an error. The driver logs
"setup already done — falling back to local login"and continues. - Cookie's `domain` is rewritten on injection.
curlsaves it aslocalhost; the screenshot runs inside the docker network where the host islibrariarr-dev.screenshot.mjsoverridesdomainto matchLIBRARIARR_BASE_URL's hostname so the cookie is sent.
Troubleshooting
| Symptom | Fix |
|---|---|
app did not become ready within 120s from up | ./driver.sh logs — Prisma schema push can take 30-60s on first start; if it's stuck on "Waiting for database", the dev DB container may have failed health checks. ./driver.sh clean && ./driver.sh up. |
setup returns 500 | Almost always a serializable-isolation conflict from two concurrent setup attempts. Re-run setup. |
screenshot says could not import 'playwright' | The bind-mount of node_modules didn't land. Delete .claude/skills/run-librariarr/node_modules and re-run. |
screenshot says Host system is missing dependencies | You're running screenshot.mjs outside the container. The skill never expects this — use ./driver.sh screenshot, which runs it inside the Playwright image. |
could not find docker network for container 'librariarr-dev' | Dev stack isn't up. ./driver.sh up first. |
setupRequired:true after setup succeeded | Stale cookie jar from a prior clean. The driver clears it on clean — if it's still there, rm .claude/skills/run-librariarr/.cookies && ./driver.sh setup. |
Port 3000 already in use on up | docker ps — another container is bound to it. ./driver.sh down first, or stop the other container. |
Files
.claude/skills/run-librariarr/
SKILL.md ← this file
driver.sh ← bash dispatcher (up | setup | smoke | logs | down | clean | screenshot)
screenshot.mjs ← Playwright helper called from `driver.sh screenshot`
package.json ← declares playwright@1.60.0 (installed via --ignore-workspace)
.gitignore ← excludes node_modules/, pnpm-lock.yaml, .cookies, screenshots/node_modules/
pnpm-lock.yaml
.cookies
screenshots/
#!/usr/bin/env bash
# driver.sh — launch + drive Librariarr from a clean machine.
#
# All paths are absolute to the project root, derived from this script's
# location, so it works whether you `cd` to the repo first or not.
set -Eeuo pipefail
SKILL_DIR="$(cd -- "$(dirname -- "${BASH_SOURCE[0]}")" && pwd)"
REPO_ROOT="$(cd -- "${SKILL_DIR}/../../.." && pwd)"
COOKIE_JAR="${SKILL_DIR}/.cookies"
SHOT_DIR="${SKILL_DIR}/screenshots"
BASE_URL="${LIBRARIARR_BASE_URL:-http://localhost:3000}"
ADMIN_USER="${LIBRARIARR_ADMIN_USER:-admin}"
ADMIN_PASS="${LIBRARIARR_ADMIN_PASS:-librariarr-dev-pw-1234}"
# Endpoints used by `smoke`. All return 200 on a fresh install with the
# admin session cookie.
SMOKE_PATHS=(
/api/auth/check-setup
/api/system/info
/api/servers
/api/settings/auth
)
log() { printf '[run-librariarr] %s\n' "$*" >&2; }
die() { log "ERROR: $*"; exit 1; }
need() {
command -v "$1" >/dev/null 2>&1 || die "missing required tool: $1"
}
cmd_up() {
need docker
need curl
cd "${REPO_ROOT}"
log "starting dev stack (pnpm docker:dev:detach) …"
pnpm docker:dev:detach
log "polling ${BASE_URL}/api/health (up to 120s) …"
for i in $(seq 1 120); do
code=$(curl -s -m 2 -o /dev/null -w '%{http_code}' "${BASE_URL}/api/health" || true)
if [ "${code}" = "200" ]; then
log "ready after ${i}s — ${BASE_URL}"
return 0
fi
sleep 1
done
die "app did not become ready within 120s — try: ./driver.sh logs"
}
# Create the first user via /api/auth/setup; if setup is already done
# (HTTP 403), fall through to /api/auth/local/login. Either way we end
# with a session cookie in ${COOKIE_JAR}.
cmd_setup() {
need curl
need jq
mkdir -p "${SKILL_DIR}"
rm -f "${COOKIE_JAR}"
local body="{\"username\":\"${ADMIN_USER}\",\"password\":\"${ADMIN_PASS}\"}"
log "POST /api/auth/setup as ${ADMIN_USER}"
local code
code=$(curl -s -m 10 -o /tmp/run-librariarr-setup.json -w '%{http_code}' \
-X POST "${BASE_URL}/api/auth/setup" \
-H 'Content-Type: application/json' \
-c "${COOKIE_JAR}" \
-d "${body}")
case "${code}" in
200)
log "setup succeeded; cookie saved to ${COOKIE_JAR}"
jq -c '.user' /tmp/run-librariarr-setup.json >&2 || true
return 0
;;
403)
log "setup already done — falling back to local login"
;;
*)
log "setup failed (HTTP ${code}); response:"; cat /tmp/run-librariarr-setup.json >&2
die "unexpected status ${code} from /api/auth/setup"
;;
esac
rm -f "${COOKIE_JAR}"
code=$(curl -s -m 10 -o /tmp/run-librariarr-login.json -w '%{http_code}' \
-X POST "${BASE_URL}/api/auth/local/login" \
-H 'Content-Type: application/json' \
-c "${COOKIE_JAR}" \
-d "${body}")
if [ "${code}" = "200" ]; then
log "login succeeded; cookie saved to ${COOKIE_JAR}"
jq -c '.user' /tmp/run-librariarr-login.json >&2 || true
else
log "login failed (HTTP ${code}); response:"; cat /tmp/run-librariarr-login.json >&2
die "could not authenticate as ${ADMIN_USER} — wrong LIBRARIARR_ADMIN_PASS?"
fi
}
cmd_smoke() {
need curl
[ -s "${COOKIE_JAR}" ] || die "no cookie jar at ${COOKIE_JAR} — run \`./driver.sh setup\` first"
local failed=0
for path in "${SMOKE_PATHS[@]}"; do
local code size
code=$(curl -s -b "${COOKIE_JAR}" -o /tmp/run-librariarr-smoke.json -w '%{http_code}' "${BASE_URL}${path}")
size=$(wc -c < /tmp/run-librariarr-smoke.json)
printf ' HTTP %s %-25s %4d bytes %s\n' "${code}" "${path}" "${size}" "$(head -c 100 /tmp/run-librariarr-smoke.json)" >&2
if [ "${code}" != "200" ]; then failed=$((failed + 1)); fi
done
if [ "${failed}" -gt 0 ]; then
die "${failed} smoke check(s) failed"
fi
log "smoke OK"
}
cmd_logs() {
cd "${REPO_ROOT}"
local n="${1:-100}"
docker logs --tail="${n}" librariarr-dev
}
cmd_down() {
cd "${REPO_ROOT}"
pnpm docker:dev:down
}
# WIPES THE DB VOLUME — use to force the next `up` to re-run schema push
# and the next `setup` to create a fresh admin.
cmd_clean() {
cd "${REPO_ROOT}"
pnpm docker:dev:clean
rm -f "${COOKIE_JAR}"
}
# Headless screenshot of a path on the running app.
#
# Runs the official Playwright image so we don't have to install Chromium's
# system libs (libnss3 / libnspr4 / libasound2 …) on the host. The Playwright
# container joins the same docker network as the dev stack and reaches the
# app by its compose service name, which works portably across Linux / WSL /
# Docker Desktop without needing `--network=host` or `host.docker.internal`.
#
# First call: docker pulls the ~2GB image. Cached after that.
PLAYWRIGHT_IMAGE="${LIBRARIARR_PLAYWRIGHT_IMAGE:-mcr.microsoft.com/playwright:v1.60.0-noble}"
APP_CONTAINER="${LIBRARIARR_APP_CONTAINER:-librariarr-dev}"
cmd_screenshot() {
need docker
need pnpm
local path="${1:-/}"
local out="${2:-${SHOT_DIR}/$(date +%Y%m%d-%H%M%S).png}"
mkdir -p "$(dirname -- "${out}")"
[ -s "${COOKIE_JAR}" ] || log "warning: no cookie jar — screenshot will be of the login page (run \`setup\` first)"
# The Playwright image ships browser binaries + OS deps but no JS package
# (intentional — you BYO the SDK version). So install the JS half locally
# and mount it in. --ignore-workspace keeps pnpm from walking up to the
# main app's lockfile and silently no-op'ing the install.
if [ ! -d "${SKILL_DIR}/node_modules/playwright" ]; then
log "installing playwright JS into ${SKILL_DIR} (one-time) …"
( cd "${SKILL_DIR}" && pnpm install --ignore-workspace --prefer-offline ) >&2
fi
# Discover the docker network the dev app is on so we can join it. Compose
# picks a network name like `librariarr_default` — don't hard-code it.
local network
network=$(docker inspect "${APP_CONTAINER}" \
--format '{{range $k, $v := .NetworkSettings.Networks}}{{$k}}{{end}}' 2>/dev/null || true)
[ -n "${network}" ] || die "could not find docker network for container '${APP_CONTAINER}' — is the dev stack up? (\`./driver.sh up\`)"
# In-network URL: speak to the app by its container name, not localhost.
local in_network_url="http://${APP_CONTAINER}:3000"
# Cookie-jar mount is optional: docker would create an empty dir at the
# mount point if the source file is missing, which screenshot.mjs would
# then try to parse as a Netscape jar. Only mount when the file exists.
local cookie_mount=()
if [ -s "${COOKIE_JAR}" ]; then
cookie_mount=(-v "${COOKIE_JAR}:/cookies:ro" -e LIBRARIARR_COOKIE_JAR=/cookies)
fi
log "screenshotting ${in_network_url}${path} → ${out}"
docker run --rm \
-u "$(id -u):$(id -g)" \
--network "${network}" \
-v "${SKILL_DIR}/screenshot.mjs:/screenshot.mjs:ro" \
-v "${SKILL_DIR}/node_modules:/node_modules:ro" \
"${cookie_mount[@]}" \
-v "$(dirname -- "${out}"):/out" \
-e NODE_PATH=/node_modules \
-e LIBRARIARR_BASE_URL="${in_network_url}" \
-e LIBRARIARR_SHOT_PATH="${path}" \
-e LIBRARIARR_SHOT_OUT="/out/$(basename -- "${out}")" \
"${PLAYWRIGHT_IMAGE}" \
node /screenshot.mjs
}
usage() {
cat <<'EOF' >&2
Usage: ./driver.sh <command> [args]
up Bring up docker dev stack, wait for /api/health 200
setup Create or log into the admin user; save session cookie
smoke GET a few authenticated endpoints, assert 200
logs [N] Tail the app container logs (default 100 lines)
down Stop containers (keep DB volume)
clean Stop containers AND wipe DB volume + cookie
screenshot [path] [out]
Headless-Chromium screenshot of a path
(default: '/'). Installs Playwright on first use.
Environment overrides:
LIBRARIARR_BASE_URL default http://localhost:3000
LIBRARIARR_ADMIN_USER default admin
LIBRARIARR_ADMIN_PASS default librariarr-dev-pw-1234
EOF
exit 1
}
[ $# -ge 1 ] || usage
sub="$1"; shift || true
case "${sub}" in
up) cmd_up "$@" ;;
setup) cmd_setup "$@" ;;
smoke) cmd_smoke "$@" ;;
logs) cmd_logs "$@" ;;
down) cmd_down "$@" ;;
clean) cmd_clean "$@" ;;
screenshot) cmd_screenshot "$@" ;;
*) usage ;;
esac
{
"name": "run-librariarr-driver",
"private": true,
"type": "module",
"description": "Headless driver deps for the /run-librariarr skill. Isolated from the main app's package.json — install with `pnpm install --ignore-workspace`.",
"dependencies": {
"playwright": "1.60.0"
}
}
#!/usr/bin/env node
// screenshot.mjs — headless-Chromium screenshot of a path on the running app.
//
// Called from driver.sh, never run directly by humans. Reads its inputs
// from env so the shell side doesn't have to worry about argv quoting:
//
// LIBRARIARR_BASE_URL base URL of the running app (e.g. http://localhost:3000)
// LIBRARIARR_COOKIE_JAR Netscape-format cookie file from `curl -c`
// LIBRARIARR_SHOT_PATH path to load on the app (e.g. /)
// LIBRARIARR_SHOT_OUT output PNG path
// LIBRARIARR_VIEWPORT_W optional viewport width (default 1440)
// LIBRARIARR_VIEWPORT_H optional viewport height (default 900)
//
// The Playwright import is dynamic so a missing install gives a clean
// error message instead of an ESM resolution stack trace.
import { readFileSync, existsSync } from "node:fs";
const env = (name, fallback) => process.env[name] ?? fallback;
const baseUrl = env("LIBRARIARR_BASE_URL", "http://localhost:3000");
const cookieJar = env("LIBRARIARR_COOKIE_JAR", "");
const shotPath = env("LIBRARIARR_SHOT_PATH", "/");
const shotOut = env("LIBRARIARR_SHOT_OUT", "screenshot.png");
const viewportW = parseInt(env("LIBRARIARR_VIEWPORT_W", "1440"), 10);
const viewportH = parseInt(env("LIBRARIARR_VIEWPORT_H", "900"), 10);
let chromium;
try {
({ chromium } = await import("playwright"));
} catch {
console.error(
"could not import 'playwright' — install with: pnpm dlx playwright@1.60.0 install chromium"
);
process.exit(2);
}
// Parse the librariarr_session line out of curl's Netscape-format jar.
// Cookie attributes are tab-separated: domain, includeSub, path, secure, expiry, name, value.
// curl prefixes HttpOnly entries with `#HttpOnly_` on the domain field — we
// strip that prefix instead of treating the line as a comment, otherwise
// the only cookie we care about (which IS HttpOnly) gets filtered out.
function loadSessionCookies() {
if (!cookieJar || !existsSync(cookieJar)) return [];
const url = new URL(baseUrl);
return readFileSync(cookieJar, "utf8")
.split("\n")
.map((line) => line.replace(/^#HttpOnly_/, ""))
.filter((line) => line && !line.startsWith("#"))
.map((line) => line.split("\t"))
.filter((parts) => parts.length >= 7 && parts[5] === "librariarr_session")
.map((parts) => ({
name: parts[5],
value: parts[6],
domain: url.hostname,
path: parts[2] || "/",
httpOnly: true,
secure: false,
sameSite: "Lax",
}));
}
const browser = await chromium.launch();
const context = await browser.newContext({
viewport: { width: viewportW, height: viewportH },
});
const cookies = loadSessionCookies();
if (cookies.length) {
await context.addCookies(cookies);
} else {
console.warn("[screenshot] no session cookie loaded — capturing as anonymous");
}
const page = await context.newPage();
const target = baseUrl.replace(/\/+$/, "") + (shotPath.startsWith("/") ? shotPath : "/" + shotPath);
try {
await page.goto(target, { waitUntil: "networkidle", timeout: 30_000 });
} catch (err) {
console.error(`[screenshot] navigation failed for ${target}: ${err.message}`);
await browser.close();
process.exit(3);
}
await page.screenshot({ path: shotOut, fullPage: true });
await browser.close();
console.log(`wrote ${shotOut} (${shotPath} on ${baseUrl})`);