This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
worm is a Bash tool that opens SSH tunnels to remote Docker Compose services and exposes them locally as *.localhost hostnames via a Caddy reverse proxy running in Docker. No /etc/hosts editing — *.localhost auto-resolves to 127.0.0.1. SSH tunnels bind to Unix domain sockets under ~/.cache/worm/sockets/, not TCP ports — this prevents direct localhost:<port> access bypassing Caddy. Three scripts, no build system, no tests.
Inspired by kobemertens/worm. README is the canonical user-facing doc.
# Start Caddy (Linux)
docker compose up -d
# Start Caddy (macOS — REQUIRED override, see "macOS" below)
docker compose -f docker-compose.yml -f docker-compose.macos.yml up -d
# Open a tunnel (interactive fzf picker)
./tunnel-app.sh
./tunnel-app.sh <search-term> # filter projects
./tunnel-app.sh -o # then pick a specific service (not just virtuoso)
./tunnel-app.sh -v ... # verbose / debug
./tunnel-app.sh --refresh # re-query remote hosts for project list
# Inspect / clean up
./list-tunnels # active tunnels; also prunes stale state files
docker compose logs -f caddy
docker exec worm-caddy caddy config # dump live Caddy configState lives in ~/.cache/worm/:
<host>.txt— cached project list per SSH hosttunnels/<domain>.json— one file per active tunnelsockets/<domain>.sock— Unix socket each SSH-Lbinds to; mounted into Caddy as/socketsssh-control/— SSH ControlMaster sockets (connection multiplexing)
The sockets directory must be created by the script before Caddy starts: if docker compose up runs first, Docker creates the bind-mount target as root and the user can no longer write to it. Always start tunnels via ./tunnel-app.sh (which mkdirs first), not docker compose up -d directly.
Three executables, all Bash:
tunnel-app.sh— entry point. Reads~/.ssh/configfor hosts, filters viaignore-hosts.txt(exact match, one host per line), queriesdocker compose lson each remote, fzf-picks a project/service, then delegates tocontainer-tunnel-caddy.container-tunnel-caddy— does the actual work: opens anssh -L <socket>:<container_ip>:<port>tunnel bound to a Unix socket (withStreamLocalBindUnlink=yesso a stale socket from a prior run is replaced), writes a state file, regeneratesCaddyfilefrom all current state files, hits Caddy's admin API (localhost:2019) to reload. Cleans up sockets and state on Ctrl+C.list-tunnels— reads~/.cache/worm/tunnels/*.json, prunes entries whose tunnel is dead.
Caddyfile is generated, not hand-edited. auto_https off and admin API on 2019 are set globally. Each tunnel becomes one <domain>.localhost:80 { reverse_proxy unix//sockets/<domain>.sock } block. The Caddyfile is regenerated by cp (preserves inode) so the bind-mount inside the running Caddy container sees the new content without restart — don't switch to atomic-rename writes here.
Discovery relies on Docker Compose labels (com.docker.compose.project, com.docker.compose.service) on the remote — projects not started via Compose won't appear.
macOS support is recent and load-bearing. Anything platform-sensitive in container-tunnel-caddy must keep both branches working:
is_macos()(checks$OSTYPE) gates platform branches.docker-compose.macos.ymlis a required override on macOS (addsnetwork_mode: bridge,ports: 80/443). Don't fold its contents into the base compose file — the base usesnetwork_mode: hostfor Linux. The socket volume mount lives in the base compose file and is inherited by both platforms.- The socket bind mount (
${HOME}/.cache/worm/sockets:/sockets) needs Docker Desktop to share~/.cache(under/Users/$USERby default — already shared on macOS). VirtioFS is required for Unix sockets to propagate; gRPC-FUSE doesn't work. Recent Docker Desktop defaults to VirtioFS. - BSD vs GNU:
sed -i ''(macOS) vssed -i(Linux);statflags also differ. Match the existing pattern when adding new calls.
- Container name
worm-caddyis referenced from multiple scripts — don't rename without updating call sites. - Caddy reloads via
docker exec worm-caddy caddy reload --config /etc/caddy/Caddyfile, which talks to its own admin API (localhost:2019). Don't switch the file write to a temp+rename pattern — that breaks the bind-mount inode and Caddy reloads stale content. ignore-hosts.txtis exact-match only (no globs, no regex). One host per line.- Default remote port is
8890(Virtuoso); the Virtuoso service path automatically appends/sparqlto the printed URL.
Runtime only — fzf, openssh-client, docker (with Compose v2). No package manager / lockfile.