# plwr
Clean CLI for Playwright browser automation with CSS selectors. Built on [playwright-rs](https://github.com/padamson/playwright-rust).
## Install
```bash
./script/install # installs to ~/.local/bin
./script/install -d ~/bin # custom directory
```
Requires Playwright browsers:
```bash
npx playwright install chromium
```
For video conversion to non-webm formats, install [ffmpeg](https://ffmpeg.org/).
## Usage
Start a browser session, navigate, interact, and stop:
```bash
plwr start # start headless browser
plwr open https://example.com # navigate to URL
plwr text h1 # Example Domain
plwr stop # shut down browser
```
### Environment variables
| `PLAYWRIGHT_HEADED` | Set to any value to run the browser with a visible window |
| `PLWR_SESSION` | Default session name (default: `default`) |
| `PLWR_TIMEOUT` | Default timeout in ms (default: `5000`) |
All commands take `-S`/`--session` and `-T`/`--timeout` as global options,
which override the environment variables.
### Starting and stopping
`start` launches the browser. All other commands require a running session.
Use `--headed` (or the `PLAYWRIGHT_HEADED` env var) to show the browser window.
```bash
plwr start # headless
plwr start --headed # visible browser window
plwr start --video recording.mp4 # record video of session
plwr stop # shut down (saves video if recording)
```
Commands that interact with page content (`text`, `click`, `wait`, `eval`,
etc.) require a page to be open first via `plwr open`. Commands that configure
the session (`header`, `viewport`) work before any page is opened.
### Navigation
`open` navigates the current page within the existing browser context. Headers,
cookies, and other state are preserved across navigations. There is no separate
`goto` command — `open` always reuses the same context. If you need a fresh
context, use `plwr stop` followed by `plwr start` and `plwr open`.
```bash
plwr open "https://example.com"
plwr reload
plwr url
```
### Waiting
```bash
plwr wait .my-element
plwr wait-not .loading-spinner -T 10000
plwr wait-any '.success' '.error' '.timeout' # prints first match
plwr wait-all '.header' '.sidebar' '.content'
```
### Interaction
All interaction commands (`click`, `fill`, `hover`, `check`, etc.) auto-wait
for the element to appear and become actionable before performing the action,
up to the timeout (`-T`, default 5000ms). You rarely need an explicit
`plwr wait` before an interaction — just use the interaction directly:
```bash
plwr click '#submit-btn' # waits for button, then clicks
plwr fill '#name-input' 'Alice' -T 10000 # waits up to 10s, then fills
```
```bash
plwr click '#submit-btn'
plwr fill '#name-input' 'Alice'
plwr press Enter
plwr press Control+c
plwr dblclick '.editable-cell' # double-click
plwr hover '.dropdown-trigger' # hover (for tooltips, menus)
plwr focus '#search' # focus an element
plwr blur '#email' # unfocus an element
plwr scroll '.footer' # scroll element into view
```
Supported keys for `press`: `a`–`z`, `A`–`Z`, `0`–`9`, `Backspace`, `Tab`,
`Enter`, `Escape`, `Space`, `Delete`, `Insert`, `ArrowUp`, `ArrowDown`,
`ArrowLeft`, `ArrowRight`, `Home`, `End`, `PageUp`, `PageDown`, `F1`–`F12`,
`Control`, `Shift`, `Alt`, `Meta`, and any US keyboard character
(`` !@#$%^&*()_+-=[]{}\\|;:'",./<>?`~ ``). Chords use `+`: `Control+c`,
`Shift+Enter`, `Meta+a`.
### Checkboxes and radios
```bash
plwr check '#agree-terms' # check a checkbox or radio
plwr uncheck '#newsletter' # uncheck a checkbox
```
### Select dropdowns
```bash
plwr select '#country' us # select by value
plwr select '#country' --label 'Canada' # select by visible text
plwr select '#colors' red green blue # multi-select
```
### Querying
Like interaction commands, `text`, `attr`, `inner-html`, and `input-value`
auto-wait for the element to appear before reading its value.
```bash
plwr text h1 # print textContent
plwr inner-html '.content' # print innerHTML (preserves tags)
plwr attr a href # print attribute value
plwr input-value '#email' # print value of input/textarea/select
plwr count '.list-item' # print number of matches
plwr exists '.sidebar' # exit 0 if found, 1 if not
```
### Headers
Set extra HTTP headers sent with every request. Headers persist across
navigations within the same session. Can be set before or after `open`.
```bash
plwr header CF-Access-Client-Id "$CLIENT_ID"
plwr header CF-Access-Client-Secret "$CLIENT_SECRET"
plwr open "$WORKER_URL" # headers sent automatically
plwr header --clear # remove all extra headers
```
### Cookies
```bash
plwr cookie session_id abc123 # set on current page's URL
plwr cookie token xyz --url https://example.com
plwr cookie --list # list all cookies as JSON
plwr cookie --clear # remove all cookies
```
### Viewport
```bash
plwr viewport 1280 720 # desktop
plwr viewport 375 667 # iPhone SE
```
### File uploads
```bash
plwr input-files 'input[type=file]' photo.png
plwr input-files '#upload' a.txt b.txt c.txt # multiple files
plwr input-files '#upload' # clear selection
```
### JavaScript
```bash
plwr eval "document.title"
plwr eval "({a: 1, b: [2, 3]})" # returns pretty-printed JSON
```
### DOM tree
```bash
plwr tree # full page tree as JSON
plwr tree '.sidebar' # subtree rooted at selector
```
### Screenshots
```bash
plwr screenshot
plwr screenshot --selector '.chart' --path chart.png
```
### Video
Record a session by passing `--video` to `start`. The video is saved when
`stop` is called. Non-webm formats (e.g. `.mp4`) require [ffmpeg](https://ffmpeg.org/).
```bash
plwr start --video recording.mp4 # start with video recording
plwr open https://example.com
# ... do stuff ...
plwr stop # saves recording.mp4
```
### Sessions
Run multiple independent browser sessions in parallel:
```bash
plwr -S session-a start
plwr -S session-b start
plwr -S session-a open https://example.com
plwr -S session-b open https://other.com
plwr -S session-a text h1 # Example Domain
plwr -S session-b text h1 # other.com's h1
plwr -S session-a stop
plwr -S session-b stop
```
## Selectors
Playwright uses its own selector engine that extends CSS. Most standard CSS
selectors work directly, but some advanced pseudo-classes need a `css=` prefix
to bypass Playwright's parser.
### Basics
```bash
plwr click '#submit-btn' # by id
plwr click '.btn.primary' # compound class
plwr click 'button' # by tag
plwr count 'input[type=email]' # attribute match
plwr count 'input[type=text]' # no quotes needed
```
### Combinators
```bash
plwr count '#list > li' # child
plwr count 'h1 + p' # adjacent sibling
plwr count 'h1 ~ p' # general sibling
plwr text '.card p' # descendant
```
### Attribute selectors
Unquoted attribute values work directly. For quoted values, use the `css=`
prefix (see [css= prefix](#css-prefix) below).
```bash
plwr count 'a[data-external]' # has attribute
plwr count 'a[href^=/]' # starts with
plwr count 'a[href$=.pdf]' # ends with
plwr count 'a[href*=example]' # contains
plwr count '[data-testid=login-form]' # exact match (no quotes)
```
### Pseudo-classes that work without prefix
```bash
plwr click 'li:first-child'
plwr click 'li:last-child'
plwr text '#list li:nth-child(2)' # second item
plwr count '#list li:nth-child(odd)' # 1st, 3rd, ...
plwr count 'li:not(.done)'
plwr count '.card:has(img)'
plwr count 'div:empty'
plwr count 'input:checked'
plwr count 'input:disabled'
plwr count 'input:enabled'
plwr count 'input:required'
```
### Playwright extensions
These are Playwright-specific and don't exist in standard CSS:
```bash
plwr click ':has-text("Sign in")' # contains text
plwr click 'text=Sign in' # text shorthand
plwr click 'li.item >> nth=0' # first match (0-based)
plwr click 'li.item >> nth=-1' # last match
plwr text ':nth-match(li.item, 2)' # alternative to nth=
plwr count 'button:visible' # only visible elements
plwr text 'tr:has-text("Bob") >> td.name' # chain with >>
```
The `>>` operator chains selectors — each segment is scoped to the previous
match. You can mix CSS and Playwright engines:
```bash
plwr text '#data-table >> tr:has-text("Alice") >> td.status'
```
### css= prefix
Playwright's selector parser auto-detects whether a string is CSS, XPath, or a
Playwright selector. Some valid CSS pseudo-classes confuse the auto-detection
because Playwright tries to interpret parenthesized arguments or quoted strings
as its own syntax. Prefixing with `css=` forces native CSS evaluation.
**Need `css=` prefix:**
| `:last-of-type` | `css=.list span:last-of-type` |
| `:first-of-type` | `css=.list p:first-of-type` |
| `:nth-of-type()` | `css=span:nth-of-type(2)` |
| `:nth-last-child()` | `css=li:nth-last-child(1)` |
| `:is()` | `css=:is(.card, .sidebar)` |
| `:where()` | `css=:where(.card, .sidebar) > p` |
| Quoted `[attr="val"]` | `css=[data-testid="login-form"]` |
```bash
plwr text 'css=.mixed span:last-of-type'
plwr text 'css=li:nth-of-type(2)'
plwr count 'css=:is(.card, .sidebar)'
plwr text 'css=[data-testid="login-form"] button'
```
**Work without prefix** (Playwright recognizes these natively):
`:nth-child()`, `:first-child`, `:last-child`, `:not()`, `:has()`, `:empty`,
`:checked`, `:disabled`, `:enabled`, `:required`, `:visible`, `:has-text()`,
`text=`, `>> nth=N`.
### Strict mode
Playwright locators are strict by default — if a selector matches multiple
elements, commands like `text`, `click`, and `attr` will fail. Use `>> nth=N`
or `:nth-match()` to pick one:
```bash
plwr text 'li.item' # fails if >1 match
plwr text 'li.item >> nth=0' # first match
plwr text ':nth-match(li.item, 2)' # second match (1-based)
plwr count 'li.item' # count always works
plwr exists 'li.item' # exists always works
```
### Shell quoting
Watch out for shell metacharacters in selectors. The `$` in `$=` will be
interpreted by bash if not single-quoted:
```bash
plwr count "a[href$=.pdf]" # ✗ bash eats the $
plwr count 'a[href$=.pdf]' # �� single quotes
```
## Example: cctr e2e test
Before (with raw `playwright-cli run-code`):
```
===
send a message
===
./pw --session=e2e run-code "async page => {
const input = await page.waitForSelector('.chat-input', { timeout: 2000 });
await input.fill('Hello agent');
await page.keyboard.press('Enter');
await page.waitForFunction(() => {
const msgs = document.querySelectorAll('[data-role=assistant]');
return Array.from(msgs).some(m => m.textContent.includes('Hi'));
}, { timeout: 5000 });
}"
---
```
After (with `plwr`):
```
===
send a message
===
plwr fill '.chat-input' 'Hello agent'
plwr press Enter
---
===
agent responds
===
plwr wait '[data-role=assistant]:has-text("Hi")' -T 10000
---
```