Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
110 changes: 103 additions & 7 deletions packages/cli/src/repl.ts
Original file line number Diff line number Diff line change
Expand Up @@ -948,13 +948,17 @@ async function startRelayLoop(
const historyDir = path.join(os.homedir(), '.playwright-repl');
const historyFile = path.join(historyDir, '.repl-history');

const promptReady = `${c.cyan}relay>${c.reset} `;
const session = new SessionManager();

const promptReady = () => session.mode === 'recording'
? `${c.red}⏺${c.reset} ${c.cyan}relay>${c.reset} `
: `${c.cyan}relay>${c.reset} `;
const promptCont = `${c.dim}...${c.reset} `;

const rl = readline.createInterface({
input: process.stdin,
output: process.stdout,
prompt: promptReady,
prompt: promptReady(),
historySize: 500,
});

Expand All @@ -975,7 +979,7 @@ async function startRelayLoop(
}
const command = buffer.trim();
buffer = '';
rl.setPrompt(promptReady);
rl.setPrompt(promptReady());

if (!command || command.startsWith('#')) return;

Expand All @@ -993,14 +997,55 @@ async function startRelayLoop(
fs.appendFileSync(historyFile, command + '\n');
} catch { /* ignore */ }

// ── Recording commands ──────────────────────────────────────────
const cmdName = command.split(/\s+/)[0].toLowerCase();
if (cmdName === 'start-recording') {
const filename = command.split(/\s+/)[1] || undefined;
try {
const file = session.startRecording(filename);
log(`${c.red}⏺${c.reset} Recording to ${c.bold}${file}${c.reset}`);
rl.setPrompt(promptReady());
} catch (err: unknown) { log(`${c.yellow}${(err as Error).message}${c.reset}`); }
return;
}
if (cmdName === 'stop-recording') {
try {
const { filename, count } = session.save();
log(`${c.green}✓${c.reset} Saved ${count} commands to ${c.bold}${filename}${c.reset}`);
rl.setPrompt(promptReady());
} catch (err: unknown) { log(`${c.yellow}${(err as Error).message}${c.reset}`); }
return;
}
if (cmdName === 'pause-recording') {
try {
const paused = session.togglePause();
log(paused ? `${c.yellow}⏸${c.reset} Recording paused` : `${c.red}⏺${c.reset} Recording resumed`);
} catch (err: unknown) { log(`${c.yellow}${(err as Error).message}${c.reset}`); }
return;
}
if (cmdName === 'discard-recording') {
try {
session.discard();
log(`${c.yellow}Recording discarded${c.reset}`);
rl.setPrompt(promptReady());
} catch (err: unknown) { log(`${c.yellow}${(err as Error).message}${c.reset}`); }
return;
}

const startTime = performance.now();
const result = await relayExec(command, page, context, expect);
const elapsed = (performance.now() - startTime).toFixed(0);

if (!result.isError) {
if (cmdName === 'snapshot' && result.text) session.setSnapshot(result.text);
session.record(command);
}

if (result.text) {
// Pass command name so filterResponse keeps the right sections
const parsed = parseInput(command);
const cmdName = parsed?._[0];
const filtered = filterResponse(result.text, cmdName);
const filteredName = parsed?._[0];
const filtered = filterResponse(result.text, filteredName);
if (filtered !== null) {
log(result.isError ? `${c.red}${filtered}${c.reset}` : filtered);
}
Expand All @@ -1026,7 +1071,7 @@ async function startRelayLoop(
process.exit(0);
});
rl.on('SIGINT', () => {
if (buffer) { buffer = ''; rl.setPrompt(promptReady); rl.prompt(); }
if (buffer) { buffer = ''; rl.setPrompt(promptReady()); rl.prompt(); }
else rl.close();
});
}
Expand Down Expand Up @@ -1253,8 +1298,59 @@ export async function startRepl(opts: ReplOpts = {}): Promise<void> {

if (opts.http) {
const httpPort = opts.httpPort ?? DEFAULT_HTTP_PORT;
const httpSession = new SessionManager();
const runner: CommandRunner = {
run: async (command: string) => relayExec(command, relayPage, relayCtx, pwExpect),
run: async (command: string) => {
const trimmed = command.trim();
const cmdName = trimmed.split(/\s+/)[0].toLowerCase();

// ── Recording commands ────────────────────────────────────
if (cmdName === 'start-recording') {
const filename = trimmed.split(/\s+/)[1] || undefined;
try {
const file = httpSession.startRecording(filename);
return { text: `Recording to ${file}`, isError: false };
} catch (err: unknown) {
return { text: (err as Error).message, isError: true };
}
}
if (cmdName === 'stop-recording') {
try {
const { filename, count } = httpSession.save();
return { text: `Saved ${count} commands to ${filename}`, isError: false };
} catch (err: unknown) {
return { text: (err as Error).message, isError: true };
}
}
if (cmdName === 'pause-recording') {
try {
const paused = httpSession.togglePause();
return { text: paused ? 'Recording paused' : 'Recording resumed', isError: false };
} catch (err: unknown) {
return { text: (err as Error).message, isError: true };
}
}
if (cmdName === 'discard-recording') {
try {
httpSession.discard();
return { text: 'Recording discarded', isError: false };
} catch (err: unknown) {
return { text: (err as Error).message, isError: true };
}
}

const result = await relayExec(trimmed, relayPage, relayCtx, pwExpect);

if (!result.isError) {
// Feed snapshot for ref-to-locator resolution
if (cmdName === 'snapshot' && result.text) {
httpSession.setSnapshot(result.text);
}
httpSession.record(trimmed);
}

return result;
},
runScript: async (script: string) => relayExec(script, relayPage, relayCtx, pwExpect),
};
await startHttpServer(httpPort, runner, log);
Expand Down
167 changes: 167 additions & 0 deletions packages/stagecraft/AGENT.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,167 @@
# Stagecraft Agent Guide

Instructions for AI agents using playwright-repl and stagecraft to record and run skills.

## Setup

You need two terminals:

**Terminal 1** — start the playwright-repl server with your logged-in Chrome session:
```bash
playwright-repl --http
```

**Terminal 2** — run stagecraft commands:
```bash
stagecraft list
stagecraft run <skill-name> --http
```

## Recording a New Skill

### Step 1: Navigate the site manually in Chrome and log in

Skills require authentication (Rogers, Bell, etc.). Log in manually in Chrome before recording. The bridge connects to your existing session — no credentials needed in the script.

### Step 2: Start recording

Via MCP `run_command`:
```
start-recording skills/rogers/my-new-skill.pw
```

### Step 3: Take a snapshot before each action

Refs (e1, e5, etc.) are only stable within a session. The recorder converts them to stable text locators (`button "Submit"`) using the most recent snapshot. Always snapshot before clicking refs:

```
snapshot
click e5 ← recorded as: click button "View your bill"
snapshot
click e12 ← recorded as: click link "Billing history"
```

If you skip `snapshot`, the ref stays as-is (e5) and won't work in future sessions.

### Step 4: Stop recording

```
stop-recording
```

Output: `Recording saved: skills/rogers/my-new-skill.pw (7 commands)`

### Step 5: Write the SKILL.md

Create `skills/rogers/my-new-skill/SKILL.md`:

```markdown
---
name: my-new-skill
description: Short description of what this skill does
category: tax/bills/telecom
preconditions: Must be logged into rogers.com (use bridge mode)
parameters:
- billing_period: billing period label, e.g. "January 24, 2026"
output: description of what the skill produces
---

# My New Skill

Longer description with context and notes for the agent.
```

### Step 6: Replay to verify

```bash
stagecraft run my-new-skill --http -v billing_period="January 24, 2026"
```

Or via MCP:
```
run_script("replay skills/rogers/my-new-skill.pw", language="pw")
```

## Anatomy of a .pw Skill File

```pw
# Rogers bill download
# Navigate to MyRogers billing page and download bill PDFs

goto https://www.rogers.com/consumer/self-serve/overview
click "View your bill" --exact
click "Save PDF"
check "{{billing_period}}"
download-as {{filename}}
click "Download bills"
```

- Lines starting with `#` are comments
- `{{variable}}` placeholders are substituted at run time
- Text locators (`"View your bill"`) are stable across sessions
- Ref locators (`e5`) are session-only — avoid them in saved skills

## For Complex Logic: Use a .js Skill

When you need loops, conditionals, downloads with `saveAs`, or multi-step data extraction — write a `.js` skill alongside the `.pw` file.

The `.js` file is a top-level `await`-capable script with `page`, `context`, and `expect` in scope.

```js
// skills/rogers/download-bill.js
const periods = JSON.parse('{{periods}}');
const savePath = '{{savePath}}';

await page.goto('https://www.rogers.com/consumer/self-serve/overview');
// ...

const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByText('Download bills').click(),
]);

if (savePath) {
await download.saveAs(savePath);
savePath;
} else {
download.suggestedFilename();
}
```

Run via:
```bash
stagecraft run download-rogers-bill --http \
--variable periods='["January 24, 2026"]' \
--variable savePath="/home/user/tax/rogers-2026-01.pdf"
```

## Skill Directory Layout

```
skills/
└── rogers/
├── SKILL.md # metadata (required)
├── download-bill.pw # keyword script (simple flows)
└── download-bill.js # Playwright JS (complex logic, downloads)
```

Add your own skills to `~/.stagecraft/skills/` — they appear in `stagecraft list` marked `[user]` and override builtin skills of the same name.

## Available Commands Reference

Run `help` via MCP to get the full list. Key commands for recording flows:

| Command | Purpose |
|---------|---------|
| `goto <url>` | Navigate |
| `snapshot` | Get accessibility tree (required before using refs) |
| `click <text\|ref>` | Click element |
| `fill <text\|ref> <value>` | Fill input |
| `check <text\|ref>` | Check checkbox |
| `select <text\|ref> <value>` | Select dropdown option |
| `verify-text <text>` | Assert text is present |
| `download-as <filename>` | Set filename for next browser download |
| `start-recording [file]` | Begin recording to .pw file |
| `stop-recording` | Save recording |
| `pause-recording` | Pause/resume |
| `discard-recording` | Abandon recording |
44 changes: 30 additions & 14 deletions packages/stagecraft/skills/rogers/download-bill.js
Original file line number Diff line number Diff line change
@@ -1,21 +1,37 @@
/**
* Download Rogers bill PDFs for specified billing periods.
* Uses global `page` from the service worker context.
* Runs via playwright-repl --http /run-script endpoint (relay mode).
*
* @param {string[]} periods - Billing period labels, e.g. ["January 24, 2026", "February 24, 2026"]
* @param {string} [filename] - Save path relative to Downloads, e.g. "bills/rogers-2026-03.pdf"
* Variables (substituted by stagecraft before sending):
* {{periods}} - JSON array of billing period labels, e.g. ["January 24, 2026"]
* {{savePath}} - Absolute path to save the PDF, e.g. "/home/user/tax/rogers-2026-03.pdf"
*
* Usage:
* stagecraft run download-rogers-bill --http \
* --variable periods='["January 24, 2026"]' \
* --variable savePath="/home/user/tax/rogers-2026-03.pdf"
*/
async function downloadRogersBill(periods, filename) {
await page.goto('https://www.rogers.com/consumer/self-serve/overview', { waitUntil: 'domcontentloaded' });
await page.getByText('View your bill').filter({ visible: true }).first().click();
await page.getByText('Save PDF').click();

await page.getByText('Download one or more bills').waitFor();
for (const period of periods) {
await page.getByRole('checkbox', { name: period }).check();
}
const periods = JSON.parse('{{periods}}');
const savePath = '{{savePath}}';

await page.goto('https://www.rogers.com/consumer/self-serve/overview', { waitUntil: 'domcontentloaded' });
await page.getByText('View your bill').filter({ visible: true }).first().click();
await page.getByText('Save PDF').click();
await page.getByText('Download one or more bills').waitFor();

for (const period of periods) {
await page.getByRole('checkbox', { name: period }).check();
}

const [download] = await Promise.all([
page.waitForEvent('download'),
page.getByText('Download bills').click(),
]);

if (filename) downloadAs(filename);
await page.getByText('Download bills').click();
return filename || 'Downloads folder (default name)';
if (savePath && savePath !== '{{savePath}}') {
await download.saveAs(savePath);
savePath;
} else {
download.suggestedFilename();
}
Loading
Loading