Move make-widget/run-server skills to root .claude/skills/

Nested .claude/skills/ dirs (previously under server/) are only
auto-discovered on-demand once a file under that subdirectory is
touched, so /make-widget and /run-server weren't invocable from a
fresh session. Root .claude/skills/ is scanned at session start.

Fixes setup.sh/start-server.sh's relative cd-depth math (was hardcoded
for the old server/.claude/skills/run-server/ depth) to instead
resolve the repo root via git and cd into server/ explicitly, and
updates SKILL.md/driver.py's path references to match the new layout.
This commit is contained in:
Thomas Faour
2026-07-25 12:09:39 +00:00
parent b2f63601c0
commit f1fda9bdee
8 changed files with 13 additions and 8 deletions
+234
View File
@@ -0,0 +1,234 @@
---
name: run-server
description: Build, run, and drive the espresso_frame FastAPI server (server/) -- start it against a scratch DB, browser-test its UI, run its pytest suite. Use when asked to start the server, take a screenshot of a frame page, click through the web UI, or verify a server/UI change actually works.
---
The espresso_frame server is a FastAPI app (`app.main:app`) with a
server-rendered Jinja UI. For agent/automated use it's driven by a
Playwright REPL at `.claude/skills/run-server/driver.py`, run under
tmux -- `chromium-cli` isn't available in this container, so this
driver replaces it (same command vocabulary: nav/wait-for/click/fill/
screenshot/eval/console-errors).
This container ships with **no Python, Node, Docker, or browser, and
no sudo**. `setup.sh` bootstraps everything non-root; it's the bulk of
what makes this skill non-obvious. Run it once per fresh container.
Commands below (`setup.sh`, `start-server.sh`, `stop-server.sh`,
`env.sh`, `driver.py`) are invoked from the **repo root** via their
`.claude/skills/run-server/` path -- the scripts `cd` into `server/`
themselves. Anything under `.venv/` (the venv itself, `pytest`,
`uvicorn`) lives inside `server/`, so those commands need `server/`
prefixed or `cd server` first.
## Prerequisites
None to install manually -- `setup.sh` does it all without root, using
only `curl`/`apt-get download`/`dpkg-deb -x` (never `apt-get install`,
which needs root). It downloads ~450MB total (Python, Chromium, tmux,
shared libs) on first run.
```bash
bash .claude/skills/run-server/setup.sh
```
Re-running is safe and fast -- every step checks whether it already
happened (venv exists? chromium downloaded? libs extracted? tmux
present?) before doing any work.
This creates:
- `.venv/` -- Python 3.12 + `requirements.txt` + `playwright`, via `uv`
(a static Rust binary that fetches its own Python -- no compiler
needed, install via `curl -LsSf https://astral.sh/uv/install.sh | sh`)
- `~/.cache/ms-playwright/` -- Chromium (full `chrome` + headless-shell)
- `/tmp/run-server-chromium-deps/` -- Chromium's + tmux's shared libs
and fonts, extracted (not installed) from `.deb` files
- `.claude/skills/run-server/env.sh` -- the `PATH`/`LD_LIBRARY_PATH`/
`FONTCONFIG_PATH`/`RUN_SERVER_CHROME_BIN` exports the driver needs
(gitignored -- host-specific `/tmp` paths, regenerated by setup.sh)
## Run (agent path)
**1. Start the server** against a scratch DB (never the real
deployment's data -- see `CLAUDE.md`):
```bash
bash .claude/skills/run-server/start-server.sh
# -> server PID <pid> up on http://127.0.0.1:8420 (log: /tmp/run-server-scratch/server.log)
```
Optional args: `start-server.sh [scratch-dir] [port]` (defaults
`/tmp/run-server-scratch` / `8420`).
**2. Drive it**, wrapped in tmux so you can send one command at a time
and read the response before sending the next. `tmux` itself was
extracted the same non-root way as Chromium's libs (see Prerequisites)
and needs `LD_LIBRARY_PATH` set in *this* shell too, not just inside
the pane -- source `env.sh` before the first `tmux` call:
```bash
source .claude/skills/run-server/env.sh
tmux new-session -d -s runserver -x 200 -y 50
tmux send-keys -t runserver \
'source .claude/skills/run-server/env.sh && server/.venv/bin/python .claude/skills/run-server/driver.py' Enter
timeout 20 bash -c 'until tmux capture-pane -t runserver -p | grep -q "driver>"; do sleep 0.3; done'
# Every fresh scratch DB starts with no users -- bootstrap-admin
# completes first-run /setup and logs in (see Commands table):
tmux send-keys -t runserver 'bootstrap-admin' Enter
timeout 15 bash -c 'until tmux capture-pane -t runserver -p | grep -q "bootstrapped admin"; do sleep 0.3; done'
tmux send-keys -t runserver 'nav /frames/1/config' Enter
tmux send-keys -t runserver 'wait-for #frame-preview-thumb' Enter
tmux send-keys -t runserver 'screenshot before' Enter
tmux capture-pane -t runserver -p
```
Poll for a specific marker between `send-keys` and `capture-pane`
(`driver>`, `bootstrapped admin`, `screenshot:`, ...) rather than a
fixed `sleep` -- it's faster and fails loudly instead of capturing a
half-rendered screen. Give each poll its own `timeout` (~15-20s); don't
chain many polling loops inside one shell invocation -- see Gotchas.
Screenshots land in `/tmp/run-server-shots/` (override:
`SCREENSHOT_DIR`). **Actually look at them** -- a blank or error-page
screenshot is a failure to launch, not success.
**Test every UI change at both a desktop and a mobile viewport.** The
driver defaults to a desktop size (1280x900); switch with `viewport`.
The app's mobile breakpoint is 860px (`theme.css`) -- below that the
sidebar goes off-canvas behind a hamburger (`.mobile-bar`). A page that
looks right at 1280px can still overflow, overlap the mobile bar, or
mis-center a `<dialog>` at phone widths -- screenshot both:
```bash
tmux send-keys -t runserver 'viewport 1280 900' Enter # desktop (also the default)
tmux send-keys -t runserver 'screenshot desktop-x' Enter
tmux send-keys -t runserver 'viewport 390 844' Enter # iPhone-ish mobile width
tmux send-keys -t runserver 'screenshot mobile-x' Enter
```
### Driver commands
| command | what it does |
|---|---|
| `nav <path-or-url>` | navigate (relative paths resolve against `http://127.0.0.1:8420`, override with `RUN_SERVER_BASE_URL`) |
| `wait-for <selector>` | wait up to 10s for a selector (plain CSS -- see Gotchas for attribute selectors) |
| `click <selector>` | click an element |
| `fill <selector> <value>` | fill an input |
| `press <key>` | keyboard press (e.g. `Enter`) |
| `screenshot [name]` | → `/tmp/run-server-shots/<name>.png` |
| `eval <js>` | evaluate JS in the page, prints JSON |
| `console-errors` | prints all captured console/page errors as a JSON array |
| `viewport [w] [h]` | resize the viewport, default `390 844` -- use `1280 900` for desktop (see Gotchas re: real touch input) |
| `is-open <dialog-selector>` | prints `true`/`false` for a `<dialog>` element's `.open` -- use this instead of `wait-for sel[open]` |
| `bootstrap-admin [user] [pass]` | completes first-run `/setup` (defaults `admin`/`testpassword123`); links frame #1 and logs in |
| `quit` | closes the browser, exits the driver |
**3. Stop the server** when done:
```bash
bash .claude/skills/run-server/stop-server.sh
tmux kill-session -t runserver
```
## Run (human path)
```bash
cd server
DATABASE_URL="sqlite:////tmp/dev.db" CONFIG_PATH="/tmp/dev-config.json" \
.venv/bin/uvicorn app.main:app --reload --port 8420
```
Open `http://localhost:8420` in a real browser. `start.sh` (the Docker
entrypoint) is not this -- it also launches the Node whiteboard
render-service sidecar, which needs Node (not installed here) and
isn't needed for most UI testing.
## Test
```bash
cd server && .venv/bin/pytest
```
Uses its own tempfile SQLite per run (`tests/conftest.py`) -- no setup
needed beyond the venv.
---
## Gotchas
- **`viewport` only resizes the window -- it does not emulate touch
input.** `click` still dispatches a mouse click, not a tap; there's
no touch-delay, no `:hover`-stickiness-after-tap, no `hasTouch`
context. It catches real bugs (layout overflow, off-canvas sidebar,
a `<dialog>` mis-centering at phone widths) but won't catch anything
that's specifically a touch-vs-mouse event difference. Good enough
for CSS/layout verification; not a substitute for testing on an
actual phone if a change touches touch-specific interaction.
- **`chrome-headless-shell` (Playwright's default headless target)
crashes on basic calls in this container**, e.g. `page.set_content()`
returns `TargetClosedError`, even after every `ldd`-reported missing
library is resolved. The full `chrome` binary (`chromium-*/chrome-linux64/chrome`)
+ `--no-sandbox` is stable; `driver.py` and `setup.sh` both use it,
not the headless-shell default.
- **Missing fonts silently break `fill()`, not just rendering.** Before
`fontconfig`/`libfontconfig1` were extracted and `fonts.conf` pointed
at the extracted font dir, `page.fill()` ran with no error but left
inputs empty (`input_value()` returned `""`), and all text rendered
invisible in screenshots. It looks like a scripting bug, not a
missing-lib problem -- if `fill` silently no-ops, suspect fonts
before suspecting the selector or a race.
- **No `apt-get install` / `playwright install-deps` (no root) and no
`apt-get update` into the real `/var/lib/apt/lists` (root-owned).**
Worked around by redirecting apt's state dirs to a scratch,
user-writable path (`-o Dir::State::Lists=... -o Dir::Cache=...`),
which makes plain `update` and `install --download-only --print-uris`
work as a non-root user; then `dpkg-deb -x <deb> <root>` (extract,
not install) needs no root either. `setup.sh` does this for
Chromium's deps *and* for `tmux` itself, which also isn't
preinstalled.
- **`wait-for` with an attribute selector like `#frame-preview-dialog[open]`
is unreliable through `tmux send-keys`** -- shell/tmux escaping of
`[`/`]` easily mangles it (seen: a real 10s Playwright timeout from a
garbled selector, not a fast failure). Use the app-specific `is-open
<selector>` command instead of `wait-for sel[open]` to check a
`<dialog>`'s open state.
- **Don't chain many `tmux send-keys` + polling-`timeout` loops inside
one shell invocation.** Each poll can legitimately take up to its own
timeout (e.g. 15s) if a selector is wrong; five or six chained in one
command can add up past this tool's own command timeout even though
each individual step is fine. Send one or two commands per shell
call and check the pane before continuing.
- **A crashed `chrome-headless-shell` process can leave a large core
dump file** (`server/core`, ~170MB, from the crash described above)
if core dumps are enabled. It's not part of the app -- delete it, and
use full `chrome` (as `driver.py` does) to avoid triggering it again.
## Troubleshooting
- **`error while loading shared libraries: libglib-2.0.so.0` (or similar)
when launching Chromium directly**: `LD_LIBRARY_PATH` isn't set --
source `.claude/skills/run-server/env.sh` first, or run through
`driver.py`, which reads `RUN_SERVER_CHROME_BIN` from it.
- **`fc-list` prints nothing after extracting fonts**: `fonts.conf`'s
`<dir>` entries still point at the real (unpopulated) `/usr/share/fonts`.
`setup.sh` patches this with `sed`; if you extracted packages by hand,
do the same.
- **`E: Could not open lock file ... Permission denied` from `apt-get`**:
you're missing the `-o Dir::State::Lists=... -o Dir::Cache=...`
overrides -- plain `apt-get update`/`install` always needs root here.
- **`tmux: command not found`**: not preinstalled and no sudo; run
`setup.sh`, which fetches it the same non-root way as Chromium's libs.
- **`tmux: error while loading shared libraries: libutempter.so.0`**:
you sourced `env.sh` inside the driver's tmux pane but not in the
shell that *invokes* `tmux` itself -- `tmux` was extracted from the
same non-root `.deb` set as Chromium and needs `LD_LIBRARY_PATH` too.
`source .claude/skills/run-server/env.sh` before the first `tmux`
command, not just inside `send-keys`.