diff --git a/packages/cli/src/repl.ts b/packages/cli/src/repl.ts index 29819d9f..283323fd 100644 --- a/packages/cli/src/repl.ts +++ b/packages/cli/src/repl.ts @@ -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, }); @@ -975,7 +979,7 @@ async function startRelayLoop( } const command = buffer.trim(); buffer = ''; - rl.setPrompt(promptReady); + rl.setPrompt(promptReady()); if (!command || command.startsWith('#')) return; @@ -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); } @@ -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(); }); } @@ -1253,8 +1298,59 @@ export async function startRepl(opts: ReplOpts = {}): Promise { 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); diff --git a/packages/stagecraft/AGENT.md b/packages/stagecraft/AGENT.md new file mode 100644 index 00000000..6dc0d598 --- /dev/null +++ b/packages/stagecraft/AGENT.md @@ -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 --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 ` | Navigate | +| `snapshot` | Get accessibility tree (required before using refs) | +| `click ` | Click element | +| `fill ` | Fill input | +| `check ` | Check checkbox | +| `select ` | Select dropdown option | +| `verify-text ` | Assert text is present | +| `download-as ` | 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 | diff --git a/packages/stagecraft/skills/rogers/download-bill.js b/packages/stagecraft/skills/rogers/download-bill.js index dc8b45ca..23570668 100644 --- a/packages/stagecraft/skills/rogers/download-bill.js +++ b/packages/stagecraft/skills/rogers/download-bill.js @@ -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(); } diff --git a/packages/stagecraft/src/skills.ts b/packages/stagecraft/src/skills.ts index 711a2cd3..201e4467 100644 --- a/packages/stagecraft/src/skills.ts +++ b/packages/stagecraft/src/skills.ts @@ -17,9 +17,10 @@ export interface SkillInfo { preconditions?: string; parameters?: { name: string; description: string }[]; output?: string; - dir: string; // absolute path to skill directory - pwFile?: string; // path to .pw file (if exists) - jsFile?: string; // path to .js file (if exists) + dir: string; // absolute path to skill directory + pwFile?: string; // path to .pw file (if exists) + jsFile?: string; // path to .js file (if exists) + source: 'builtin' | 'user'; // where the skill was discovered } // ─── Frontmatter parsing ──────────────────────────────────────────────────── @@ -76,10 +77,9 @@ function parseFrontmatter(content: string): Record | null { // ─── Discovery ────────────────────────────────────────────────────────────── /** - * Discover all skills in the given directory. - * Each subdirectory with a SKILL.md is treated as a skill. + * Scan a single directory for skills. Returns unsorted results. */ -export function discoverSkills(skillsDir: string): SkillInfo[] { +function scanSkillsDir(skillsDir: string, source: 'builtin' | 'user'): SkillInfo[] { if (!fs.existsSync(skillsDir)) return []; const skills: SkillInfo[] = []; @@ -91,11 +91,37 @@ export function discoverSkills(skillsDir: string): SkillInfo[] { const skillMd = path.join(dir, 'SKILL.md'); if (!fs.existsSync(skillMd)) continue; - const info = parseSkillMd(skillMd, dir); + const info = parseSkillMd(skillMd, dir, source); if (info) skills.push(info); } - // Sort by category then name + return skills; +} + +/** + * Discover all skills from both the builtin skills directory and the user + * skills directory (~/.stagecraft/skills/). User skills with the same name + * as a builtin skill take precedence (override). + * + * @param builtinDir - absolute path to the package's bundled skills directory + * @param userDir - user skills directory (default: ~/.stagecraft/skills) + */ +export function discoverSkills(builtinDir: string, userDir?: string): SkillInfo[] { + const resolvedUserDir = userDir ?? path.join( + process.env['HOME'] || process.env['USERPROFILE'] || '~', + '.stagecraft', + 'skills', + ); + + const builtin = scanSkillsDir(builtinDir, 'builtin'); + const user = scanSkillsDir(resolvedUserDir, 'user'); + + // User skills override builtin skills of the same name + const byName = new Map(); + for (const skill of builtin) byName.set(skill.name, skill); + for (const skill of user) byName.set(skill.name, skill); // overrides + + const skills = Array.from(byName.values()); skills.sort((a, b) => a.category.localeCompare(b.category) || a.name.localeCompare(b.name)); return skills; } @@ -103,7 +129,7 @@ export function discoverSkills(skillsDir: string): SkillInfo[] { /** * Parse a single SKILL.md file into a SkillInfo object. */ -function parseSkillMd(filepath: string, dir: string): SkillInfo | null { +function parseSkillMd(filepath: string, dir: string, source: 'builtin' | 'user'): SkillInfo | null { const content = fs.readFileSync(filepath, 'utf-8'); const data = parseFrontmatter(content); if (!data) return null; @@ -128,6 +154,7 @@ function parseSkillMd(filepath: string, dir: string): SkillInfo | null { dir, pwFile: pwFile ? path.join(dir, pwFile) : undefined, jsFile: jsFile ? path.join(dir, jsFile) : undefined, + source, }; } diff --git a/packages/stagecraft/src/stagecraft.ts b/packages/stagecraft/src/stagecraft.ts index c1169801..9cd6f58d 100644 --- a/packages/stagecraft/src/stagecraft.ts +++ b/packages/stagecraft/src/stagecraft.ts @@ -5,21 +5,28 @@ * * Usage: * stagecraft list List available skills - * stagecraft run Run a skill's .pw file - * --variable key=value Set template variables + * stagecraft run Run a skill's .pw file or .js script + * --variable key=value Set template variables (for .pw files) + * --http Connect to running playwright-repl --http server + * --http-port HTTP server port (default: 9223) */ import path from 'node:path'; +import os from 'node:os'; +import fs from 'node:fs'; import { minimist, SessionPlayer, resolveCommand } from '@playwright-repl/core'; import { discoverSkills, findSkill } from './skills.js'; const args = minimist(process.argv.slice(2), { - string: ['variable'], + string: ['variable', 'http-port', 'skills-dir'], + boolean: ['http'], alias: { v: 'variable' }, }); const command = args._[0]; const skillsDir = path.resolve(import.meta.dirname, '..', 'skills'); +const userSkillsDir = (args['skills-dir'] as string | undefined) + ?? path.join(os.homedir(), '.stagecraft', 'skills'); if (!command || command === 'help') { printHelp(); @@ -43,20 +50,29 @@ function printHelp() { stagecraft — skill library for playwright-repl Usage: - stagecraft list List available skills - stagecraft run Run a skill's .pw file + stagecraft list List available skills (builtin + ~/.stagecraft/skills/) + stagecraft run Run a skill's .pw file or .js script --variable key=value (-v) Set template variables (repeatable) + --http Connect to running playwright-repl --http server + --http-port HTTP server port (default: 9223) + --skills-dir Override user skills directory + +User skills live in ~/.stagecraft/skills/ — create a subdirectory with a SKILL.md to add your own. +User skills with the same name as a builtin skill override it. Examples: stagecraft list stagecraft run download-rogers-bill -v billing_period="January 24, 2026" + stagecraft run download-rogers-bill --http -v billing_period="January 24, 2026" `.trim()); } -function listSkills(dir?: string) { - const skills = discoverSkills(dir || skillsDir); +function listSkills() { + const skills = discoverSkills(skillsDir, userSkillsDir); if (skills.length === 0) { console.log('No skills found.'); + console.log(` Builtin: ${skillsDir}`); + console.log(` User: ${userSkillsDir}`); return; } @@ -73,7 +89,8 @@ function listSkills(dir?: string) { for (const [category, items] of grouped) { console.log(` ${category}`); for (const skill of items) { - const name = skill.name.padEnd(24); + const tag = skill.source === 'user' ? ' [user]' : ''; + const name = (skill.name + tag).padEnd(30); console.log(` ${name}${skill.description}`); } } @@ -86,7 +103,7 @@ async function runSkill() { process.exit(1); } - const skills = discoverSkills(skillsDir); + const skills = discoverSkills(skillsDir, userSkillsDir); const skill = findSkill(skills, skillName); if (!skill) { console.error(`Skill not found: ${skillName}`); @@ -94,25 +111,129 @@ async function runSkill() { process.exit(1); } - if (!skill.pwFile) { - console.error(`Skill '${skillName}' has no .pw file to run.`); + if (!skill.pwFile && !skill.jsFile) { + console.error(`Skill '${skillName}' has no .pw or .js file to run.`); process.exit(1); + return; } // Parse --variable args into a Record const variables = parseVariables(args.variable as string | string[] | undefined); - // Load commands via SessionPlayer - const commands = SessionPlayer.load(skill.pwFile, variables); - console.log(`Running skill: ${skill.name}`); if (skill.preconditions) { console.log(` Note: ${skill.preconditions}`); } - console.log(` Commands: ${commands.length}`); + + const port = args['http-port'] ? parseInt(args['http-port'] as string, 10) : 9223; + + if (args.http as boolean) { + // Prefer .js over .pw in HTTP mode — full Playwright API, handles downloads natively + if (skill.jsFile) { + await runSkillHttpJs(skill.jsFile, variables, port); + } else { + const commands = SessionPlayer.load(skill.pwFile!, variables); + console.log(` Commands: ${commands.length}`); + console.log(''); + await runSkillHttpPw(commands, port); + } + } else { + if (!skill.pwFile) { + console.error(`Direct mode only supports .pw files. Use --http to run .js skills.`); + process.exit(1); + return; + } + const commands = SessionPlayer.load(skill.pwFile, variables); + console.log(` Commands: ${commands.length}`); + console.log(''); + await runSkillDirect(commands); + } +} + +// ─── HTTP helpers ──────────────────────────────────────────────────────────── + +async function ensureHttpServer(base: string, port: number): Promise { + try { + const res = await fetch(`${base}/health`); + if (!res.ok) throw new Error(`HTTP ${res.status}`); + } catch { + console.error(`Cannot connect to playwright-repl server on port ${port}.`); + console.error(`Start it with: playwright-repl --http --http-port ${port}`); + process.exit(1); + } +} + +// ─── HTTP .js mode: send script via /run-script ────────────────────────────── +// Preferred for skills with complex logic (downloads, loops, conditionals). + +async function runSkillHttpJs(jsFile: string, variables: Record | undefined, port: number): Promise { + const base = `http://localhost:${port}`; + await ensureHttpServer(base, port); + + let script = fs.readFileSync(jsFile, 'utf-8'); + // Substitute {{variable}} placeholders if present + if (variables) { + for (const [key, value] of Object.entries(variables)) { + script = script.replaceAll(`{{${key}}}`, value); + } + } + + console.log(` Running .js skill via /run-script`); console.log(''); - // Launch browser via Playwright (dynamic import to avoid compile-time type dep) + try { + const res = await fetch(`${base}/run-script`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ script, language: 'javascript' }), + }); + const result = await res.json() as { text: string; isError: boolean }; + if (result.isError) { + console.error(`ERROR: ${result.text}`); + process.exit(1); + } + if (result.text) console.log(result.text); + } catch (err: unknown) { + console.error(`ERROR: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + console.log('\nDone.'); +} + +// ─── HTTP .pw mode: send keyword commands one by one via /run ───────────────── + +async function runSkillHttpPw(commands: string[], port: number): Promise { + const base = `http://localhost:${port}`; + await ensureHttpServer(base, port); + + for (let i = 0; i < commands.length; i++) { + const cmd = commands[i]; + console.log(` [${i + 1}/${commands.length}] ${cmd}`); + + try { + const res = await fetch(`${base}/run`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ command: cmd }), + }); + const result = await res.json() as { text: string; isError: boolean }; + if (result.isError) { + console.error(` ERROR: ${result.text}`); + process.exit(1); + } + if (result.text) console.log(` → ${result.text}`); + } catch (err: unknown) { + console.error(` ERROR: ${err instanceof Error ? err.message : String(err)}`); + process.exit(1); + } + } + console.log('\nDone.'); +} + +// ─── Direct mode: launch fresh Playwright browser ──────────────────────────── + +async function runSkillDirect(commands: string[]): Promise { + // Dynamic import to avoid compile-time type dependency on playwright const dynamicImport = Function('m', 'return import(m)') as (m: string) => Promise; const { chromium } = await dynamicImport('playwright'); const browser = await chromium.launch({ headless: false });