
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](/documentation/ai-tools/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

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

### 2. Install the Plugin

```bash
/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`:
   ```ruby
   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

```bash
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.

```bash
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

```bash
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.

```bash
# 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

### OTP / magic-link login (the log trick)

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.

```bash
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.

```bash
# 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`:

```bash
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=1` → `127.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`:

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

---

## Next Steps

<div class="not-prose mt-8 grid grid-cols-1 gap-4 sm:grid-cols-2">
  <a href="https://github.com/maquina-app/rails-claude-code" target="_blank" rel="noopener" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      GitHub Repository
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      View source code and contribute.
    </p>
  </a>

  <a href="/documentation/ai-tools/rails-mcp-server/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Rails MCP Server
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Pair static code analysis with live runtime interaction.
    </p>
  </a>

  <a href="/documentation/ai-tools/better-stimulus/" class="group relative rounded-2xl border border-zinc-200 p-6 hover:border-[#50B1FD]/50 dark:border-zinc-800 dark:hover:border-[#50B1FD]/50 transition">
    <h3 class="font-sora font-semibold text-zinc-900 dark:text-white group-hover:text-[#50B1FD] transition">
      Better Stimulus
    </h3>
    <p class="mt-2 text-sm text-zinc-600 dark:text-zinc-400">
      Write the Stimulus controllers behind your Hotwire UI.
    </p>
  </a>
</div>
