From rails-hotwire-driver
Drive a running local Rails dev server from the terminal: authenticate, submit forms, trigger Turbo Streams, read dev logs, and optionally use a browser for JS-dependent tasks.
How this skill is triggered — by the user, by Claude, or both
Slash command
/rails-hotwire-driver:rails-hotwire-driverThe summary Claude sees in its skill listing — used to decide when to auto-load this skill
Exercise a **running local Rails dev server** from the shell: authenticate, submit
Exercise a running local Rails dev server from the shell: authenticate, submit
forms, trigger and read Turbo Streams, and read the development log — including
OTP/verification codes that Rails prints to the log in development. This is the
runtime complement to rails-mcp-server, which only reads code statically.
Good fit: ERB + Hotwire apps with minimal JavaScript. The server renders HTML and
text/vnd.turbo-stream.html; you are verifying that server-rendered contract.
The core scripts (req.sh, submit_form.rb, readlog.sh, flow.sh) will not
execute JavaScript, by design — no Stimulus controllers run, no DOM morphing, no
requestSubmit, no ActionCable-broadcast rendering. Don't try to fake JS here.
For the JS-dependent residual, this skill also ships a thin layer of optional scripts around the agent-browser CLI — see Pairing with a browser below. They cover the common cases (screenshots, waiting for a broadcast to actually update the DOM, Stimulus controller state, structural diffs) without reaching for a separate MCP or writing Playwright test code. Anything past that — full end-to-end flows, cross-browser testing — is still better served by a dedicated browser-testing tool.
bin/rails s), and you
know its port. Set BASE_URL (default http://localhost:3000). The scripts
refuse any non-local host (allowed: localhost, loopback IPs, and any
*.localhost name).Nokogiri is available — it ships with essentially every Rails bundle. Run the
Ruby script via the project's bundle: bundle exec ruby .../submit_form.rb.config/environments/development.rb:
config.log_tags = [ :request_id ]
Without it, readlog.sh request falls back to a context window instead of an
exact filter — still useful, just noisier.These scripts only ever talk to a local server and only read the development
log. Reading secrets like OTP codes out of a log is a development-only affordance;
readlog.sh refuses any path containing production.
*.localhost hostsIf you front your apps with kamal-proxy and reach them at names like
http://fragua.localhost, set BASE_URL=http://fragua.localhost (with the port if
not 80). The proxy routes by the Host header, which curl and Net::HTTP send
automatically from the URL — so routing just works as long as the name resolves.
*.localhost resolves to loopback automatically on macOS and most browsers, but
not always on Linux without an /etc/hosts entry or systemd-resolved config.
If a request fails to connect, force resolution to loopback with RESOLVE:
RESOLVE=1 BASE_URL=http://fragua.localhost:80 req.sh GET /
# connects to 127.0.0.1 but still sends Host: fragua.localhost, so the proxy
# routes to the right app. RESOLVE=10.0.0.5 targets a specific IP instead.
RESOLVE works for both req.sh (via curl --resolve) and submit_form.rb (via
Net::HTTP#ipaddr=); in both cases the Host header is preserved for routing.
The log scripts are unaffected — point LOG_FILE at the right app's
log/development.log, since each app under the proxy has its own log.
This skill verifies the server's contract (turbo-stream actions, SQL, logs, the raw HTML before JS runs). A real browser verifies client behavior (did Stimulus wire up, did the stream actually mutate the DOM, did a lazy frame load). They're complementary; the session bridges between them so you only log in once.
The default choice is the agent-browser CLI — the browser scripts below wrap it
directly, and it's what the rest of this section assumes. Playwright (e.g. the
Playwright MCP, npx @playwright/mcp@latest) still works exactly the same way if
that's what's already installed: the bridge scripts emit the standard storageState
format, which both consume identically. Pick one; don't run both against the same
app at once (each maintains its own idea of "the session").
The core scripts above have zero dependencies beyond curl/Ruby. The browser scripts need agent-browser installed separately:
npm install -g agent-browser
agent-browser install # downloads its own Chrome for Testing, first run only
agent-browser install --with-deps # Linux: also installs system libs (fonts, GTK, etc.)
If you already have Chrome, Brave, Playwright, or Puppeteer installed, agent-browser install detects and reuses it instead of downloading a second copy. None of this is
needed for the core curl scripts — only for screenshot.sh and the other scripts
listed under Browser scripts below.
Authenticate fast with the OTP-from-log trick, then hand the logged-in session to a real browser. The browser can't easily read the OTP out of the log, so doing login here and bridging over is the natural split.
flow.sh --email [email protected] --otp-path /session/otp --then-path / # logs in, fills jar
ruby jar_to_storage.rb --origin http://fragua.localhost > state.json
# agent-browser: agent-browser --state state.json open http://fragua.localhost
# Playwright: newContext({ storageState: 'state.json' })
# or: npx @playwright/mcp@latest --storage-state state.json
The browser scripts (screenshot.sh, screenshot-diff.sh, dom-diff.sh) do this
bridging step automatically from JAR — you don't need to run it by hand for those.
If a login is too JS-heavy for curl to replay (OAuth popup, Stimulus-driven form), let the browser do it through the real UI, export its session, and drop back to the fast curl + log tools.
# agent-browser: agent-browser --session x state save state.json
# Playwright: await context.storageState({ path: 'state.json' })
ruby storage_to_jar.rb --in state.json # writes ./.hotwire/cookies.txt
req.sh GET /dashboard # now authenticated
flow.sh, or the browser if the login needs JS).readlog.sh request <id>).Note on *.localhost: Chromium accepts cookies on localhost/*.localhost
without HTTPS, so the bridge keeps secure:false for http origins — matching how
your dev session cookie is set. If you serve dev over https, pass an https://
--origin and the bridge marks cookies secure.
All live in scripts/. Copy this skill's folder into the project (or run the
scripts by absolute path) so the cookie jar and log resolve against the app.
A shared cookie jar at ./.hotwire/cookies.txt carries the session across calls.
scripts/req.sh — one HTTP request, cookies persistedreq.sh GET /products
req.sh GET /cart turbo # Accept: text/vnd.turbo-stream.html
req.sh GET /messages frame:inbox # Turbo-Frame: inbox (load a lazy frame)
req.sh POST /cart/add 'product_id=1&qty=2'
Prints response headers (with X-Request-Id, and Set-Cookie redacted), then the
body (truncated past MAX_BYTES, default 100k). Use the printed X-Request-Id to
pull that exact request's log lines.
scripts/submit_form.rb — submit a form with the right CSRF tokenThis is the tool to reach for on any POST/PUT/PATCH/DELETE that goes through an ERB
form. It GETs the page, reads the form's hidden inputs including
authenticity_token, merges your fields over them, and submits — eliminating the
single most common hand-driving failure (a missing/stale CSRF token). It also
honors Rails' _method hidden field for non-POST verbs.
bundle exec ruby scripts/submit_form.rb /session/new "[email protected]" "password=secret"
bundle exec ruby scripts/submit_form.rb /posts/new "form#new_post" "post[title]=Hi"
Reports status, X-Request-Id, any redirect Location, and — for turbo-stream
responses — a parsed list of action #target pairs.
scripts/readlog.sh — read the dev log safelyreadlog.sh tail 200
readlog.sh grep 'SQL|SELECT' 500
readlog.sh request <x-request-id> # exact lines for one request (needs log_tags)
readlog.sh otp # grep common OTP / magic-link / token patterns
scripts/flow.sh — full login → OTP → action in one commandOrchestrates the other three: submits the login form (CSRF handled), reads the OTP from the log scoped to the login's request id (not a blind grep), submits the OTP, then optionally performs one authenticated action — all sharing the cookie jar so the action runs logged-in.
# OTP / magic-link login, then hit an authenticated page:
flow.sh --email [email protected] --password secret \
--login-path /session/new \
--otp-path /session/otp --otp-field code \
--then-path /dashboard --then-method GET
# Password-only (omit --otp-path to skip the OTP steps):
flow.sh --email [email protected] --password secret --then-path /account
# Authenticated POST through a form (CSRF auto-handled):
flow.sh --email [email protected] --otp-path /session/otp \
--then-path /posts/new --then-method POST --then-fields 'post[title]=Hi'
Key flags: --login-path (default /session/new), --login-fields 'k=v&k2=v2'
for extra login inputs, --otp-field (default code), --otp-pattern (a regex if
your log phrasing is unusual; the default matches "code is 123456", "OTP: 1234",
"code = 9981", "token=224466"), and --then-accept html|turbo|json. Set
RUBY="ruby" if you're not running inside the app's bundle (default is
bundle exec ruby).
scripts/jar_to_storage.rb / scripts/storage_to_jar.rb — browser session bridgeConvert the curl session jar to storageState JSON (agent-browser's --state,
or Playwright's newContext) and back, so you log in once and share the session
across tools. See Pairing with a browser.
ruby jar_to_storage.rb --origin http://fragua.localhost > state.json # curl -> browser
ruby storage_to_jar.rb --in state.json # browser -> curl
npm install -g agent-browser && agent-browser install (see Prerequisites for
the browser scripts above). All of these bridge the cookie jar automatically,
enforce the same local-only guardrail as the curl scripts, and derive a stable
SESSION name from BASE_URL's host so repeated calls reuse the same
already-open browser instead of relaunching one each time.
scripts/screenshot.sh — full-page, scoped, device, or dark/light screenshotsscreenshot.sh /cart # full page
screenshot.sh /cart '#cart_summary' cart.png # approximate crop to an element
screenshot.sh --device "iPhone 14" /cart # device emulation
screenshot.sh --media dark /cart # dark mode
screenshot.sh --close # shut down the browser session
No selector → full page. With a selector → sizes the viewport to the element's
bounding box (an approximation, not a pixel-exact crop). --device overrides that
and just frames on the element instead, since a device profile already fixes the
viewport. Waits on domcontentloaded, not networkidle — Hotwire apps hold an
open ActionCable connection, so networkidle can hang for the full timeout.
scripts/screenshot-diff.sh — visual regression against a saved baselinescreenshot-diff.sh save /dashboard baselines/dashboard.png
screenshot-diff.sh compare /dashboard baselines/dashboard.png
Deliberately separate from screenshot.sh: a pixel diff is only meaningful with a
fixed viewport (VIEWPORT, default 1280x800) across both runs, which
conflicts with screenshot.sh's crop-to-element behavior.
scripts/turbo-wait.sh — wait for a Turbo event or DOM conditionturbo-wait.sh dom "document.querySelector('#cart_summary')?.textContent.includes('3 items')"
turbo-wait.sh arm 'turbo:before-stream-render' # arm BEFORE the trigger
bundle exec ruby scripts/submit_form.rb /cart/add "product_id=1"
turbo-wait.sh for 'turbo:before-stream-render' 8000
dom polls a JS boolean expression — the recommended default, no race condition.
arm/for listen for a named Turbo event but must be armed before the
triggering action, or a one-shot event fired before the listener attaches is
missed. Replaces guessing a fixed sleep for "did the broadcast actually land".
scripts/stimulus.sh — introspect connected Stimulus controllersstimulus.sh tree # every connected controller: identifier, element
stimulus.sh inspect cart-item # targets + values for one, by identifier or index
Reads window.Stimulus.controllers (the default Rails stimulus:install output)
plus each controller's static targets/values. Set STIMULUS_GLOBAL if you
exposed it under a different name.
scripts/dom-diff.sh — structural diff around a Turbo actiondom-diff.sh mark '#cart_summary'
bundle exec ruby scripts/submit_form.rb /cart/add "product_id=1"
dom-diff.sh diff '#cart_summary' --compact
Diffs the accessibility tree under a selector, before vs. after — more precise than eyeballing a screenshot for confirming a stream replaced exactly what it claimed to and nothing else moved.
scripts/browser-errors.sh — console/error capture, paired with request idsbrowser-errors.sh clear
out="$(bundle exec ruby scripts/submit_form.rb /cart/add "product_id=1")"
rid="$(echo "$out" | grep -i X-Request-Id | awk '{print $2}')"
browser-errors.sh check "$rid"
readlog.sh request "$rid" # the server-side half
A workflow aid, not automatic correlation — there's no shared trace id between a
curl-issued request and a browser-side JS error. check just prints the request
id alongside the console/error output so the adjacency is easy to read.
submit_form.rb /session/new "email=..." "password=..." — token handled for you;
the session cookie lands in the jar automatically.req.sh / submit_form.rb calls reuse the jar, so you're logged in.req.sh GET <Location>.The one-command version is flow.sh (see above). Manually, the steps are:
submit_form.rb /session/new "email=..." (or the OTP request form).readlog.sh otp — or, more precisely, take the
X-Request-Id from step 1 and run readlog.sh request <id> to see only that
request's lines, then extract the 6-digit code.submit_form.rb /otp "code=123456".
This works because in development the mailer/notifier writes the code to the log
rather than sending real email.req.sh POST /cart/add 'product_id=1' turbo (or submit_form.rb for CSRF forms).action #target list to confirm the server returned the streams
you expected (e.g. replace #cart_summary, append #flash).readlog.sh request <X-Request-Id> shows which
partials rendered and what SQL ran for that exact response.Any req.sh/submit_form.rb call prints X-Request-Id. Feed it to
readlog.sh request <id> to get a clean, single-request slice of the log — the most
reliable way to see params, SQL, partial renders, and errors without log noise.
The server-side half (req.sh/submit_form.rb + readlog.sh) can't see this —
it only sees the log line, not the browser. The browser-script half can:
dom-diff.sh mark '#target' — snapshot the target before the action.submit_form.rb, a background job, another user).turbo-wait.sh dom "document.querySelector('#target')?.textContent.includes('...')"
— wait for the actual change, not a guessed sleep.dom-diff.sh diff '#target' --compact and/or screenshot.sh /page '#target'
to confirm exactly what changed.stimulus.sh tree to confirm the controller connected at all (if it's missing,
the problem is registration/data-controller, not the controller's logic).stimulus.sh inspect <identifier> to check its targets resolved and its values
hold what you expect.browser-errors.sh check to catch an uncaught exception in connect() that
would silently prevent the rest of the controller from running.readlog.sh refuses paths containing production.req.sh redacts Set-Cookie; the session value never
needs to enter the transcript. Report auth state, not the cookie.execute_ruby sandbox — that
sandbox blocks network on purpose. These scripts are a deliberately separate,
narrowly-scoped affordance.req.sh, so don't weaken it there either if you touch them.The vars you'll set most often: BASE_URL (default http://localhost:3000; for
kamal-proxy use the routed name, e.g. http://fragua.localhost), RESOLVE (force
*.localhost to loopback when it doesn't resolve), JAR (cookie jar, default
./.hotwire/cookies.txt), LOG_FILE (default ./log/development.log), and RUBY
(default bundle exec ruby).
The full list — including the browser-script vars (SESSION, VIEWPORT,
WAIT_LOAD, STIMULUS_GLOBAL, …) and their defaults — is in
references/config.md.
npx claudepluginhub maquina-app/rails-claude-code --plugin rails-hotwire-driverGuides writing, reviewing, and refactoring Hotwire-powered Rails code with patterns for Turbo Drive, Turbo Frames, Turbo Streams, Turbo 8 morphing, and Stimulus controllers.
Provides mental models, decision frameworks, and debugging patterns for Hotwire (Turbo, Stimulus, Hotwire Native) in Rails apps. Activates on common issues like Turbo Frame not updating, morphing problems, or broadcast failures.
Guides building reactive Rails apps with Hotwire (Turbo Drive/Frames/Streams, Stimulus): installation, ActionCable/Redis setup, core patterns.