Skip to main content

Rails Hotwire Driver — Maquina

Claude Code skill that drives a running local Rails dev server from the terminal — CSRF-correct form submits, OTP-from-log login, Turbo Stream inspection, request-id log correlation, and Playwright session bridging.

A Claude Code skill that drives a running local Rails dev server from the shell — no browser required. Log in (including OTP/magic-link codes read straight from the log), submit ERB forms with the correct CSRF token, inspect Turbo Stream responses, and trace any request through the development log by its request id.

It is the runtime complement to the Rails MCP Server, which only reads code statically. This skill adds live interaction with a real, running app.


What Is This?

A Claude Code skill that lets Claude:

  • Authenticate — submit login forms with the right CSRF token, and read OTP/verification codes that Rails prints to the dev log in development
  • Submit forms — GET the page, read hidden inputs (including authenticity_token), merge your fields, and POST/PUT/PATCH/DELETE through ERB forms
  • Inspect Turbo Streams — fire a request and read back the parsed action #target pairs the server returned
  • Read the log safely — tail, grep, pull OTP patterns, or slice the exact lines for one X-Request-Id
  • Bridge to Playwright — convert the curl session to/from Playwright storageState so you log in once and share the authenticated session between curl and a real browser

It is delivered as a skill (knowledge module plus shell scripts), not an autonomous agent. Claude reads SKILL.md and runs the scripts in scripts/ against your local app.


When It Fits (and When It Doesn’t)

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.

It does not execute JavaScript. No Stimulus controllers run, no DOM morphing, no requestSubmit, no ActionCable-broadcast rendering. You can see a broadcast happen in the log (via request-id correlation), but not its DOM effect. For those cases, pair it with a browser-driving tool like the Playwright MCP — the session bridge means you only log in once.


Quick Start

1. Add the Marketplace

/plugin marketplace add maquina-app/rails-claude-code

2. Install the Plugin

/plugin install rails-hotwire-driver@maquina

3. Drive Your App

With your Rails app running locally (e.g. bin/rails s), just ask:

> Log in as me@example.com and open the dashboard
> Submit the new post form and show me which turbo-streams came back
> Read the OTP code from the log and finish the login
> Trace request abc-123 through the development log

Prerequisites

Confirm these before driving:

  1. The app is running locally in development, 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.
  2. Nokogiri is available — it ships with essentially every Rails bundle. Run the Ruby scripts via the project bundle (bundle exec ruby ...).
  3. Recommended: request-id tagging for best log correlation. In 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.


The Scripts

All live in scripts/. A shared cookie jar at ./.hotwire/cookies.txt carries the session across calls.

Script Purpose
req.sh One HTTP request with cookies persisted. Prints response headers (with X-Request-Id, Set-Cookie redacted) and the body.
submit_form.rb Submit a form with the correct CSRF token. GETs the page, reads hidden inputs including authenticity_token, merges your fields, honors Rails’ _method field.
readlog.sh Read the dev log safely — tail, grep, request <id>, or otp.
flow.sh Full login → OTP → action in one command, all sharing the cookie jar.
jar_to_storage.rb / storage_to_jar.rb Bridge the curl session to/from Playwright storageState.

req.sh — one request, cookies persisted

req.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'

submit_form.rb — the right CSRF token, every time

This is the tool for any POST/PUT/PATCH/DELETE through an ERB form. It eliminates the single most common hand-driving failure — a missing or stale CSRF token.

bundle exec ruby scripts/submit_form.rb /session/new "email=me@x.com" "password=secret"
bundle exec ruby scripts/submit_form.rb /posts/new "form#new_post" "post[title]=Hi"

It reports status, X-Request-Id, any redirect Location, and — for turbo-stream responses — a parsed list of action #target pairs.

readlog.sh — read the dev log safely

readlog.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

flow.sh — login → OTP → action in one command

Orchestrates 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.

# OTP / magic-link login, then hit an authenticated page:
flow.sh --email me@x.com --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 me@x.com --password secret --then-path /account

# Authenticated POST through a form (CSRF auto-handled):
flow.sh --email me@x.com --otp-path /session/otp \
        --then-path /posts/new --then-method POST --then-fields 'post[title]=Hi'

Core Workflows

In development, the mailer/notifier writes the code to the log rather than sending real email. flow.sh does this in one command; manually the steps are:

  1. Trigger it: submit_form.rb /session/new "email=...".
  2. Read the code: take the X-Request-Id from step 1, run readlog.sh request <id>, and extract the code.
  3. Submit it: submit_form.rb /otp "code=123456".

Verify a Turbo Stream

  1. req.sh POST /cart/add 'product_id=1' turbo (or submit_form.rb for CSRF forms).
  2. Read the parsed action #target list to confirm the server returned the streams you expected (e.g. replace #cart_summary, append #flash).
  3. Correlate render details with readlog.sh request <X-Request-Id> — which partials rendered, what SQL ran.

Trace one request end to end

Any req.sh/submit_form.rb call prints X-Request-Id. Feed it to readlog.sh request <id> for a clean, single-request slice of the log — the most reliable way to see params, SQL, partial renders, and errors without log noise.


Pairing with Playwright

This skill verifies the server’s contract (turbo-stream actions, SQL, logs, the raw HTML before JS runs). Playwright verifies client behavior (did Stimulus wire up, did the stream actually mutate the DOM, did a lazy frame load). They’re complementary — the session bridge means you log in only once.

curl → Playwright (the common case)

Authenticate fast with the OTP-from-log trick, then hand the logged-in session to a real browser.

flow.sh --email me@x.com --otp-path /session/otp --then-path /
ruby jar_to_storage.rb --origin http://fragua.localhost > state.json
# then: npx @playwright/mcp@latest --storage-state state.json

Playwright → curl (reverse)

If a login is too JS-heavy for curl to replay (OAuth popup, Stimulus-driven form), let Playwright do it through the real UI, export its session, and drop back to the fast curl + log tools.

# in 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

The bridge scripts emit the standard storageState format, so they work with the Playwright MCP, the Node test runner, or playwright-ruby-client.


kamal-proxy and *.localhost Hosts

If 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.

*.localhost resolves to loopback on macOS and most browsers, but not always on Linux. Force resolution 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

RESOLVE works for both req.sh and submit_form.rb; the Host header is preserved for routing either way. Point LOG_FILE at the specific app’s log/development.log, since each app under the proxy has its own log.


Configuration

Set via environment variables:

Variable Default Purpose
BASE_URL http://localhost:3000 Target server. For kamal-proxy use the routed name.
RESOLVE (off) Force the host to resolve to an IP. RESOLVE=1127.0.0.1; RESOLVE=<ip> → that IP.
JAR ./.hotwire/cookies.txt Cookie jar path.
LOG_FILE ./log/development.log Log to read (point at the specific app’s log).
MAX_BYTES 100000 Response body cap for req.sh.

Guardrails

These are deliberate — don’t weaken them:

  • Local only. Both shell scripts reject non-localhost hosts.
  • No production logs. readlog.sh refuses paths containing production.
  • Don’t echo cookies. req.sh redacts Set-Cookie; report auth state, not the cookie value.
  • Don’t route through the rails-mcp-server execute_ruby sandbox — that sandbox blocks network on purpose. These scripts are a deliberately separate, narrowly-scoped affordance.

Package Contents

rails-hotwire-driver/
└── skills/
    └── rails-hotwire-driver/
        ├── SKILL.md                 # Skill knowledge module
        └── scripts/
            ├── req.sh               # One HTTP request, cookies persisted
            ├── submit_form.rb       # CSRF-correct form submit
            ├── readlog.sh           # Safe dev-log reader
            ├── flow.sh              # login → OTP → action
            ├── jar_to_storage.rb    # curl jar → Playwright storageState
            └── storage_to_jar.rb    # Playwright storageState → curl jar

Team Installation

Add to your project’s .claude/settings.json:

{
  "extraKnownMarketplaces": {
    "maquina": {
      "source": {
        "source": "github",
        "repo": "maquina-app/rails-claude-code"
      }
    }
  },
  "enabledPlugins": [
    "rails-hotwire-driver@maquina"
  ]
}

Next Steps