Add browser automation skill with REPL exploration and flow saving

- Interactive REPL mode for exploring pages via Playwright code chunks
- Chunk recording system for capturing successful interactions
- Flow saving with curation (select which chunks to keep)
- Flow chaining (run saved flows as chunks in exploration)
- Session folder structure for organizing screenshots
- One-shot page inspection command

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
Justin Maier
2026-01-15 23:26:20 -07:00
parent 125b161bbe
commit 5a57ba1183
8 changed files with 1346 additions and 1 deletions
+11
View File
@@ -0,0 +1,11 @@
/**
* Flow: browse-to-model
* Generated: 2026-01-16T06:05:59.839Z
* Start URL: https://civitai.com
*/
// --- Click models link ---
await page.click('a[href="/models"]'); await page.waitForSelector('a[href^="/models/"]');
// --- Click first model ---
await page.click('a[href^="/models/"]'); await page.waitForSelector('h1');
+226
View File
@@ -0,0 +1,226 @@
---
name: browser-automation
description: Run saved browser automation flows or explore pages interactively. Use for UI testing, navigation discovery, or automating browser tasks. Flows are reusable Playwright scripts that you build through exploration.
---
# Browser Automation Skill
Explore pages interactively and save successful paths as reusable flows.
## Quick Start
### Explore a Page (Interactive REPL)
```bash
node .claude/skills/browser-automation/runner.mjs --explore https://civitai.com
```
Browser opens. Send JSON commands via stdin to interact:
```json
{"cmd": "inspect"}
{"cmd": "chunk", "label": "Click models", "code": "await page.click('a[href=\"/models\"]');"}
{"cmd": "review"}
{"cmd": "save", "name": "my-flow", "keep": [1, 2]}
{"cmd": "exit"}
```
### Run a Saved Flow
```bash
node .claude/skills/browser-automation/runner.mjs --run-flow my-flow
```
### List Saved Flows
```bash
node .claude/skills/browser-automation/runner.mjs --list-flows
```
## Exploration Workflow
The core workflow is: **Explore → Record → Curate → Save → Replay**
### 1. Start Exploration
```bash
node runner.mjs --explore https://example.com
```
- Browser opens at the URL
- Returns `session_started` with page inspection (buttons, links, inputs, screenshot)
### 2. Execute Code Chunks
```json
{"cmd": "chunk", "label": "Click login button", "code": "await page.click('button.login');"}
```
- Executes the Playwright code against the page
- Records the chunk with its label
- Returns `chunk_executed` with new page inspection
### 3. Run Existing Flows (Chaining)
```json
{"cmd": "list-flows"}
{"cmd": "flow", "name": "browse-to-model"}
```
- Runs a saved flow as a chunk
- Flow's code gets inlined into the recording
- Enables building on top of existing flows
### 4. Review & Curate
```json
{"cmd": "review"}
```
Returns all recorded chunks:
```json
{
"type": "review",
"chunks": [
{"index": 1, "label": "[flow: browse-to-model]", "code": "await page.click(...)..."},
{"index": 2, "label": "Click download", "code": "await page.click('button.download');"}
]
}
```
### 5. Save Selected Chunks
```json
{"cmd": "save", "name": "download-model", "keep": [1, 2]}
```
- Concatenates selected chunks into a `.js` file
- Saves to `.browser/flows/download-model.js`
- The saved flow is self-contained (no dependencies)
### 6. Exit
```json
{"cmd": "exit"}
```
## REPL Commands Reference
| Command | Description |
|---------|-------------|
| `{"cmd": "inspect"}` | Get current page state (buttons, links, inputs, screenshot) |
| `{"cmd": "chunk", "label": "...", "code": "..."}` | Execute Playwright code and record it |
| `{"cmd": "list-flows"}` | List available saved flows |
| `{"cmd": "flow", "name": "..."}` | Run a saved flow as a chunk |
| `{"cmd": "review"}` | Show all recorded chunks |
| `{"cmd": "save", "name": "...", "keep": [1,2,3]}` | Save selected chunks as a flow |
| `{"cmd": "exit"}` | Close browser and end session |
## Writing Playwright Code
Chunks execute in an async context with `page` available:
```javascript
// Click elements
await page.click('button.submit');
await page.click('a[href="/models"]');
// Type text
await page.fill('input[name="email"]', 'test@example.com');
// Wait for elements
await page.waitForSelector('h1');
await page.waitForSelector('.loading', { state: 'hidden' });
// Extract data
const title = await page.textContent('h1');
console.log('Title:', title);
// Take screenshots
await page.screenshot({ path: '/tmp/screenshot.png' });
// Navigate
await page.goto('https://example.com');
```
## Flow Chaining Example
Build complex flows by composing simpler ones:
```bash
# Session 1: Create browse-to-model flow
node runner.mjs --explore https://civitai.com
```
```json
{"cmd": "chunk", "label": "Click models", "code": "await page.click('a[href=\"/models\"]'); await page.waitForSelector('a[href^=\"/models/\"]');"}
{"cmd": "chunk", "label": "Click first model", "code": "await page.click('a[href^=\"/models/\"]'); await page.waitForSelector('h1');"}
{"cmd": "save", "name": "browse-to-model", "keep": [1, 2]}
{"cmd": "exit"}
```
```bash
# Session 2: Build on browse-to-model to create download-model flow
node runner.mjs --explore https://civitai.com
```
```json
{"cmd": "flow", "name": "browse-to-model"}
{"cmd": "chunk", "label": "Click download", "code": "await page.click('button:has-text(\"Download\")');"}
{"cmd": "save", "name": "download-model", "keep": [1, 2]}
{"cmd": "exit"}
```
Now `download-model` is self-contained with all the code inlined.
## When Flows Fail
If a chunk or flow fails during exploration, you get:
- Error message
- Current page inspection (screenshot, buttons, links, inputs)
This lets you see what's actually on the page and adjust your code.
## One-Shot Inspection (No Session)
For quick page inspection without a full session:
```bash
node runner.mjs --inspect https://example.com
```
## CLI Reference
```bash
# Exploration (interactive REPL)
node runner.mjs --explore <url>
# Run saved flow
node runner.mjs --run-flow <name>
# List flows
node runner.mjs --list-flows
# One-shot inspect
node runner.mjs --inspect <url>
# Options
--headless Run browser without visible window
--timeout <ms> Default timeout (default: 30000)
```
## File Locations
- **Saved flows**: `.browser/flows/*.js`
- **Session folders**: `.browser/sessions/{session-id}/`
- `session.json` - Session metadata
- `screenshots/` - All screenshots from the session
- `001-session-start.png`
- `002-chunk-click-models.png`
- `003-flow-browse-to-model.png`
- etc.
## Advanced Playwright Code
Since chunks execute arbitrary Playwright code, you can do anything Playwright supports:
```javascript
// Resize viewport
await page.setViewportSize({ width: 1920, height: 1080 });
// Listen to console
page.on('console', msg => console.log('CONSOLE:', msg.text()));
// Get page HTML
const html = await page.content();
// Execute JavaScript in the page
const result = await page.evaluate(() => document.title);
// Wait for network idle
await page.waitForLoadState('networkidle');
// Handle dialogs
page.on('dialog', dialog => dialog.accept());
```
@@ -0,0 +1,285 @@
/**
* Page Inspector
*
* Provides page inspection capabilities for AI agents to explore
* and build recipes interactively.
*/
import { chromium } from 'playwright';
import { createContextCollector } from './context.mjs';
/**
* Inspect a page and return structured data about what's visible
* @param {Page} page - Playwright page
* @param {Object} options - Options
* @param {string} options.screenshotPath - Path to save screenshot
* @returns {Object} Page inspection data
*/
export async function inspectPage(page, options = {}) {
const screenshot = options.screenshotPath || `/tmp/inspect-${Date.now()}.png`;
await page.screenshot({ path: screenshot, fullPage: false }); // Viewport only for speed
const inspection = await page.evaluate(() => {
// Helper to generate a usable selector
function getSelector(el) {
if (el.id) return `#${el.id}`;
if (el.dataset.testid) return `[data-testid="${el.dataset.testid}"]`;
if (el.name) return `[name="${el.name}"]`;
// For links, prefer href-based selector
if (el.tagName === 'A' && el.getAttribute('href')) {
const href = el.getAttribute('href');
if (href && !href.startsWith('javascript:') && href !== '#') {
return `a[href='${href}']`;
}
}
// For buttons with text
if (el.tagName === 'BUTTON' || el.getAttribute('role') === 'button') {
const text = el.textContent?.trim();
if (text && text.length < 30) {
return `button:has-text('${text.replace(/'/g, "\\'")}')`;
}
}
if (el.className && typeof el.className === 'string') {
const classes = el.className.trim().split(/\s+/).slice(0, 2).join('.');
if (classes) return `${el.tagName.toLowerCase()}.${classes}`;
}
return el.tagName.toLowerCase();
}
function isVisible(el) {
if (!el.offsetParent && el.tagName !== 'BODY') return false;
const rect = el.getBoundingClientRect();
if (rect.width === 0 || rect.height === 0) return false;
const style = window.getComputedStyle(el);
return style.display !== 'none' &&
style.visibility !== 'hidden' &&
style.opacity !== '0';
}
function isInViewport(el) {
const rect = el.getBoundingClientRect();
return rect.top < window.innerHeight && rect.bottom > 0 &&
rect.left < window.innerWidth && rect.right > 0;
}
// Get clickable elements (buttons, links)
const clickable = [];
const seen = new Set();
// Buttons
document.querySelectorAll('button, [role="button"], input[type="submit"], input[type="button"]').forEach(el => {
if (!isVisible(el)) return;
const text = el.textContent?.trim() || el.value || '';
const selector = getSelector(el);
const key = `${selector}-${text}`;
if (seen.has(key)) return;
seen.add(key);
clickable.push({
type: 'button',
text: text.substring(0, 50),
selector,
inViewport: isInViewport(el),
enabled: !el.disabled,
});
});
// Links (separate from buttons to avoid dedup issues)
const links = [];
document.querySelectorAll('a[href]').forEach(el => {
if (!isVisible(el)) return;
const href = el.getAttribute('href');
if (!href || href === '#' || href.startsWith('javascript:')) return;
const text = el.textContent?.trim() || '';
// Skip if no meaningful text or href
if (!text && href.startsWith('/')) return;
const selector = getSelector(el);
links.push({
text: text.substring(0, 50),
href: href.substring(0, 100),
selector,
inViewport: isInViewport(el),
});
});
// Form inputs
const inputs = [];
document.querySelectorAll('input, textarea, select').forEach(el => {
if (!isVisible(el) || el.type === 'hidden') return;
inputs.push({
type: el.type || el.tagName.toLowerCase(),
name: el.name || el.id || '',
selector: getSelector(el),
placeholder: el.placeholder || '',
value: el.type === 'password' ? '(password)' : (el.value?.substring(0, 50) || ''),
required: el.required,
inViewport: isInViewport(el),
});
});
// Text content for context
const headings = Array.from(document.querySelectorAll('h1, h2, h3'))
.filter(isVisible)
.slice(0, 5)
.map(el => el.textContent?.trim().substring(0, 100));
// Error/alert messages
const alerts = Array.from(document.querySelectorAll('[role="alert"], .error, .alert, .error-message'))
.filter(isVisible)
.filter(el => el.textContent?.trim())
.slice(0, 3)
.map(el => el.textContent?.trim().substring(0, 200));
return {
url: window.location.href,
title: document.title,
headings,
alerts,
buttons: clickable.slice(0, 20), // Limit for readability
links: links.slice(0, 20),
inputs: inputs.slice(0, 15),
};
});
return {
...inspection,
screenshot,
};
}
/**
* Interactive browser session for exploration
*/
export class BrowserSession {
constructor(options = {}) {
this.browser = null;
this.context = null;
this.page = null;
this.collector = null;
this.headless = options.headless ?? false;
this.slowMo = options.slowMo ?? 0;
this.actions = []; // Record actions for recipe building
}
async start() {
this.browser = await chromium.launch({
headless: this.headless,
slowMo: this.slowMo,
});
this.context = await this.browser.newContext({
viewport: { width: 1280, height: 720 },
});
this.page = await this.context.newPage();
this.page.setDefaultTimeout(30000);
this.collector = createContextCollector(this.page);
}
async close() {
if (this.browser) {
await this.browser.close();
this.browser = null;
}
}
async navigate(url) {
await this.page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
// Wait a bit for JS to render
await this.page.waitForTimeout(2000);
this.actions.push({ action: 'navigate', url });
return this.inspect();
}
async click(selector) {
try {
await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
// Check if link and wait for navigation
const isLink = await this.page.evaluate(sel => {
const el = document.querySelector(sel);
return el?.tagName === 'A' && el?.href;
}, selector);
if (isLink) {
await Promise.all([
this.page.waitForNavigation({ waitUntil: 'networkidle' }).catch(() => {}),
this.page.click(selector)
]);
} else {
await this.page.click(selector);
await this.page.waitForTimeout(500); // Wait for any JS
}
this.actions.push({ action: 'click', selector });
return { success: true, inspection: await this.inspect() };
} catch (e) {
return { success: false, error: e.message, inspection: await this.inspect() };
}
}
async type(selector, text) {
try {
await this.page.waitForSelector(selector, { state: 'visible', timeout: 5000 });
await this.page.fill(selector, '');
await this.page.fill(selector, text);
this.actions.push({ action: 'type', selector, text });
return { success: true, inspection: await this.inspect() };
} catch (e) {
return { success: false, error: e.message, inspection: await this.inspect() };
}
}
async inspect() {
return inspectPage(this.page);
}
async screenshot(name) {
const path = `/tmp/${name || 'screenshot'}-${Date.now()}.png`;
await this.page.screenshot({ path, fullPage: true });
return path;
}
getRecordedActions() {
return this.actions;
}
generateRecipe(name, description) {
return {
name,
description,
steps: this.actions.map(action => {
switch (action.action) {
case 'navigate':
return { navigate: action.url };
case 'click':
return { click: action.selector };
case 'type':
return { type: { selector: action.selector, text: action.text } };
default:
return action;
}
}),
};
}
}
/**
* One-shot page inspection (opens browser, inspects, closes)
*/
export async function inspectUrl(url, options = {}) {
const session = new BrowserSession(options);
try {
await session.start();
const result = await session.navigate(url);
return result;
} finally {
await session.close();
}
}
@@ -0,0 +1,515 @@
/**
* Session Manager
*
* Manages browser sessions for exploration.
* For persistent sessions, use the REPL mode (--explore-repl).
*/
import { chromium } from 'playwright';
import { readFileSync, writeFileSync, existsSync, mkdirSync, unlinkSync, readdirSync, rmdirSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { randomBytes } from 'crypto';
import { createInterface } from 'readline';
import { inspectPage } from './inspector.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
const projectRoot = resolve(__dirname, '../../../..');
const sessionsDir = resolve(projectRoot, '.browser/sessions');
const flowsDir = resolve(projectRoot, '.browser/flows');
// Ensure directories exist
function ensureDirs() {
if (!existsSync(sessionsDir)) mkdirSync(sessionsDir, { recursive: true });
if (!existsSync(flowsDir)) mkdirSync(flowsDir, { recursive: true });
}
/**
* Generate a short session ID
*/
function generateSessionId() {
return randomBytes(4).toString('hex');
}
/**
* Get session folder path
*/
function getSessionDir(sessionId) {
return resolve(sessionsDir, sessionId);
}
/**
* Get session JSON file path
*/
function getSessionPath(sessionId) {
return resolve(getSessionDir(sessionId), 'session.json');
}
/**
* Get screenshots folder path
*/
function getScreenshotsDir(sessionId) {
return resolve(getSessionDir(sessionId), 'screenshots');
}
/**
* Ensure session directories exist
*/
function ensureSessionDirs(sessionId) {
const sessionDir = getSessionDir(sessionId);
const screenshotsDir = getScreenshotsDir(sessionId);
if (!existsSync(sessionDir)) mkdirSync(sessionDir, { recursive: true });
if (!existsSync(screenshotsDir)) mkdirSync(screenshotsDir, { recursive: true });
}
/**
* Generate screenshot path for a session
*/
function getScreenshotPath(sessionId, name, index) {
const screenshotsDir = getScreenshotsDir(sessionId);
const paddedIndex = String(index).padStart(3, '0');
// Sanitize name for filesystem
const safeName = name.replace(/[^a-z0-9-]/gi, '-').toLowerCase().substring(0, 50);
return resolve(screenshotsDir, `${paddedIndex}-${safeName}.png`);
}
/**
* Load session data from file
*/
function loadSession(sessionId) {
const path = getSessionPath(sessionId);
if (!existsSync(path)) {
throw new Error(`Session not found: ${sessionId}`);
}
return JSON.parse(readFileSync(path, 'utf-8'));
}
/**
* Save session data to file
*/
function saveSession(sessionId, data) {
ensureDirs();
ensureSessionDirs(sessionId);
const path = getSessionPath(sessionId);
writeFileSync(path, JSON.stringify(data, null, 2));
}
/**
* Delete session folder
*/
function deleteSession(sessionId) {
const sessionDir = getSessionDir(sessionId);
if (existsSync(sessionDir)) {
// Remove all files in screenshots
const screenshotsDir = getScreenshotsDir(sessionId);
if (existsSync(screenshotsDir)) {
for (const file of readdirSync(screenshotsDir)) {
unlinkSync(resolve(screenshotsDir, file));
}
rmdirSync(screenshotsDir);
}
// Remove session.json
const sessionPath = getSessionPath(sessionId);
if (existsSync(sessionPath)) {
unlinkSync(sessionPath);
}
// Remove session folder
rmdirSync(sessionDir);
}
}
/**
* Interactive REPL exploration session
* Browser stays open and accepts commands via stdin
*/
export async function startExploreRepl(url, options = {}) {
ensureDirs();
const sessionId = generateSessionId();
const headless = options.headless ?? false;
// Create session directories
ensureSessionDirs(sessionId);
console.error(`Starting exploration session: ${sessionId}`);
console.error(`Session folder: ${getSessionDir(sessionId)}`);
console.error(`Opening: ${url}`);
// Launch browser
const browser = await chromium.launch({ headless });
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
page.setDefaultTimeout(30000);
// Navigate to starting URL
await page.goto(url, { waitUntil: 'domcontentloaded', timeout: 30000 });
await page.waitForTimeout(2000);
// Session state
const chunks = [];
let currentUrl = page.url();
let screenshotIndex = 0;
// Helper to get next screenshot path
const nextScreenshot = (name) => {
screenshotIndex++;
return getScreenshotPath(sessionId, name, screenshotIndex);
};
// Get and output initial inspection
const screenshotPath = nextScreenshot('session-start');
const inspection = await inspectPage(page, { screenshotPath });
outputJson({
type: 'session_started',
sessionId,
sessionDir: getSessionDir(sessionId),
inspection,
});
// Set up readline for commands
const rl = createInterface({
input: process.stdin,
output: process.stderr, // Don't interfere with JSON output
terminal: false,
});
console.error('\nSession ready. Commands: chunk, inspect, review, save, exit');
console.error('Format: {"cmd": "chunk", "label": "...", "code": "..."}');
// Process commands
for await (const line of rl) {
if (!line.trim()) continue;
let cmd;
try {
cmd = JSON.parse(line);
} catch (e) {
outputJson({ type: 'error', message: 'Invalid JSON command' });
continue;
}
try {
switch (cmd.cmd) {
case 'chunk': {
// Execute a code chunk
const label = cmd.label || `Chunk ${chunks.length + 1}`;
const code = cmd.code;
if (!code) {
outputJson({ type: 'error', message: 'No code provided' });
break;
}
try {
const asyncFn = new Function('page', `return (async () => { ${code} })();`);
await asyncFn(page);
await page.waitForTimeout(500);
// Record chunk
const chunkIndex = chunks.length + 1;
chunks.push({
index: chunkIndex,
label,
code,
timestamp: new Date().toISOString(),
urlAfter: page.url(),
});
currentUrl = page.url();
// Get inspection with session screenshot
const screenshotPath = nextScreenshot(`chunk-${label}`);
const inspection = await inspectPage(page, { screenshotPath });
outputJson({
type: 'chunk_executed',
chunkIndex,
label,
inspection,
});
} catch (execError) {
let inspection = null;
try {
const screenshotPath = nextScreenshot(`chunk-failed-${cmd.label || 'unknown'}`);
inspection = await inspectPage(page, { screenshotPath });
} catch (e) {}
outputJson({
type: 'chunk_failed',
error: execError.message,
inspection,
});
}
break;
}
case 'inspect': {
const screenshotPath = nextScreenshot('inspect');
const inspection = await inspectPage(page, { screenshotPath });
outputJson({ type: 'inspection', inspection });
break;
}
case 'review': {
outputJson({
type: 'review',
sessionId,
startUrl: url,
currentUrl,
chunks: chunks.map(c => ({
index: c.index,
label: c.label,
code: c.code,
})),
});
break;
}
case 'list-flows': {
// List available flows
const flows = listFlowsInternal();
outputJson({
type: 'flows_list',
flows: flows.map(f => ({ name: f.name, startUrl: f.startUrl })),
});
break;
}
case 'flow': {
// Run a saved flow as a chunk
const flowName = cmd.name;
if (!flowName) {
outputJson({ type: 'error', message: 'No flow name provided' });
break;
}
const flowPath = resolve(flowsDir, `${flowName}.js`);
if (!existsSync(flowPath)) {
outputJson({ type: 'error', message: `Flow not found: ${flowName}` });
break;
}
const flowScript = readFileSync(flowPath, 'utf-8');
// Extract just the code (skip the header comments)
const codeLines = flowScript.split('\n').filter(line => {
return !line.startsWith('/**') &&
!line.startsWith(' *') &&
!line.startsWith('*/') &&
line.trim() !== '';
});
const flowCode = codeLines.join('\n');
try {
const asyncFn = new Function('page', `return (async () => { ${flowCode} })();`);
await asyncFn(page);
await page.waitForTimeout(500);
// Record as a chunk
const chunkIndex = chunks.length + 1;
chunks.push({
index: chunkIndex,
label: `[flow: ${flowName}]`,
code: flowCode,
timestamp: new Date().toISOString(),
urlAfter: page.url(),
isFlow: true,
flowName,
});
currentUrl = page.url();
const screenshotPath = nextScreenshot(`flow-${flowName}`);
const inspection = await inspectPage(page, { screenshotPath });
outputJson({
type: 'flow_executed',
chunkIndex,
flowName,
inspection,
});
} catch (execError) {
let inspection = null;
try {
const screenshotPath = nextScreenshot(`flow-failed-${flowName}`);
inspection = await inspectPage(page, { screenshotPath });
} catch (e) {}
outputJson({
type: 'flow_failed',
flowName,
error: execError.message,
inspection,
});
}
break;
}
case 'save': {
const flowName = cmd.name;
const keepIndexes = cmd.keep;
if (!flowName) {
outputJson({ type: 'error', message: 'No flow name provided' });
break;
}
if (!keepIndexes || keepIndexes.length === 0) {
outputJson({ type: 'error', message: 'No chunks selected (use "keep": [1,2,3])' });
break;
}
const selectedChunks = chunks.filter(c => keepIndexes.includes(c.index));
if (selectedChunks.length === 0) {
outputJson({ type: 'error', message: 'No matching chunks found' });
break;
}
// Build script
const scriptLines = [
`/**`,
` * Flow: ${flowName}`,
` * Generated: ${new Date().toISOString()}`,
` * Start URL: ${url}`,
` */`,
``,
];
for (const chunk of selectedChunks) {
scriptLines.push(`// --- ${chunk.label} ---`);
scriptLines.push(chunk.code);
scriptLines.push(``);
}
const script = scriptLines.join('\n');
// Save
ensureDirs();
const flowPath = resolve(flowsDir, `${flowName}.js`);
writeFileSync(flowPath, script);
outputJson({
type: 'flow_saved',
flowName,
flowPath,
chunksIncluded: selectedChunks.map(c => c.index),
script,
});
break;
}
case 'exit': {
outputJson({ type: 'session_ended', sessionId, totalChunks: chunks.length });
await browser.close();
process.exit(0);
}
default:
outputJson({ type: 'error', message: `Unknown command: ${cmd.cmd}` });
}
} catch (cmdError) {
outputJson({ type: 'error', message: cmdError.message });
}
}
// EOF - cleanup
await browser.close();
}
/**
* Output JSON to stdout (for agent consumption)
*/
function outputJson(obj) {
console.log(JSON.stringify(obj));
}
/**
* Run a saved flow
*/
export async function runFlow(flowName, options = {}) {
const flowPath = resolve(flowsDir, `${flowName}.js`);
if (!existsSync(flowPath)) {
throw new Error(`Flow not found: ${flowName}`);
}
const script = readFileSync(flowPath, 'utf-8');
// Extract start URL from script comments
const urlMatch = script.match(/Start URL: (.+)/);
const startUrl = options.startUrl || (urlMatch ? urlMatch[1] : null);
if (!startUrl) {
throw new Error('No start URL specified and could not extract from flow');
}
// Launch browser
const browser = await chromium.launch({
headless: options.headless ?? false,
});
const context = await browser.newContext({
viewport: { width: 1280, height: 720 },
});
const page = await context.newPage();
page.setDefaultTimeout(options.timeout || 30000);
try {
// Navigate to start URL
await page.goto(startUrl, { waitUntil: 'domcontentloaded' });
await page.waitForTimeout(1000);
// Execute the flow script
const asyncFn = new Function('page', `return (async () => { ${script} })();`);
await asyncFn(page);
// Get final inspection
const inspection = await inspectPage(page);
await browser.close();
return {
status: 'passed',
flowName,
inspection,
};
} catch (error) {
let inspection = null;
try {
inspection = await inspectPage(page);
} catch (e) {}
await browser.close();
return {
status: 'failed',
flowName,
error: error.message,
inspection,
};
}
}
/**
* List all saved flows (internal)
*/
function listFlowsInternal() {
ensureDirs();
const files = readdirSync(flowsDir).filter(f => f.endsWith('.js'));
return files.map(f => {
const name = f.replace('.js', '');
const content = readFileSync(resolve(flowsDir, f), 'utf-8');
const urlMatch = content.match(/Start URL: (.+)/);
return {
name,
startUrl: urlMatch ? urlMatch[1] : 'unknown',
path: resolve(flowsDir, f),
};
});
}
/**
* List all saved flows (exported)
*/
export function listFlows() {
return listFlowsInternal();
}
+59
View File
@@ -0,0 +1,59 @@
{
"name": "browser-automation-skill",
"version": "1.0.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "browser-automation-skill",
"version": "1.0.0",
"dependencies": {
"playwright": "^1.40.0"
}
},
"node_modules/fsevents": {
"version": "2.3.2",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.2.tgz",
"integrity": "sha512-xiqMQR4xAeHTuB9uWm+fFRcIOgKBMiOBP+eXiyT7jsgVCq1bkVygt00oASowB7EdtpOHaaPgKt812P9ab+DDKA==",
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/playwright": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright/-/playwright-1.57.0.tgz",
"integrity": "sha512-ilYQj1s8sr2ppEJ2YVadYBN0Mb3mdo9J0wQ+UuDhzYqURwSoW4n1Xs5vs7ORwgDGmyEh33tRMeS8KhdkMoLXQw==",
"license": "Apache-2.0",
"dependencies": {
"playwright-core": "1.57.0"
},
"bin": {
"playwright": "cli.js"
},
"engines": {
"node": ">=18"
},
"optionalDependencies": {
"fsevents": "2.3.2"
}
},
"node_modules/playwright-core": {
"version": "1.57.0",
"resolved": "https://registry.npmjs.org/playwright-core/-/playwright-core-1.57.0.tgz",
"integrity": "sha512-agTcKlMw/mjBWOnD6kFZttAAGHgi/Nw0CZ2o6JqWSbMlI219lAFLZZCyqByTsvVAJq5XA5H8cA6PrvBRpBWEuQ==",
"license": "Apache-2.0",
"bin": {
"playwright-core": "cli.js"
},
"engines": {
"node": ">=18"
}
}
}
}
@@ -0,0 +1,15 @@
{
"name": "browser-automation-skill",
"version": "1.0.0",
"description": "Browser automation skill with REPL exploration and reusable flows",
"type": "module",
"main": "runner.mjs",
"scripts": {
"start": "node runner.mjs",
"setup": "npx playwright install chromium",
"test": "node runner.mjs --list-flows"
},
"dependencies": {
"playwright": "^1.40.0"
}
}
@@ -0,0 +1,231 @@
#!/usr/bin/env node
/**
* Browser Automation Runner
*
* Supports two modes:
* 1. REPL exploration (interactive session for discovering and recording flows)
* 2. Flow execution (run saved flows directly)
*
* REPL Mode:
* node runner.mjs --explore <url>
* Then send JSON commands via stdin:
* {"cmd": "chunk", "label": "Click button", "code": "await page.click('button');"}
* {"cmd": "inspect"}
* {"cmd": "review"}
* {"cmd": "list-flows"}
* {"cmd": "flow", "name": "my-flow"}
* {"cmd": "save", "name": "my-flow", "keep": [1, 3, 4]}
* {"cmd": "exit"}
*
* Flow Commands:
* --run-flow <name> Run a saved flow
* --list-flows List all saved flows
*
* One-shot Commands:
* --inspect <url> One-shot page inspection
*/
import { existsSync } from 'fs';
import { resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { inspectUrl } from './lib/inspector.mjs';
import { startExploreRepl, runFlow, listFlows } from './lib/session.mjs';
const __dirname = dirname(fileURLToPath(import.meta.url));
// Parse command line arguments
function parseArgs() {
const args = process.argv.slice(2);
const config = {
// REPL exploration
explore: null,
// Flow commands
runFlow: null,
listFlows: false,
// One-shot commands
inspectUrl: null,
// Options
headless: process.env.BROWSER_HEADLESS === 'true',
timeout: parseInt(process.env.BROWSER_TIMEOUT || '30000', 10),
};
for (let i = 0; i < args.length; i++) {
const arg = args[i];
switch (arg) {
// REPL exploration
case '--explore':
config.explore = args[++i];
break;
// Flow commands
case '--run-flow':
config.runFlow = args[++i];
break;
case '--list-flows':
config.listFlows = true;
break;
// One-shot commands
case '--inspect':
config.inspectUrl = args[++i];
break;
// Options
case '--headless':
config.headless = true;
break;
case '--timeout':
config.timeout = parseInt(args[++i], 10);
break;
case '--help':
case '-h':
printHelp();
process.exit(0);
}
}
return config;
}
function printHelp() {
console.log(`
Browser Automation Runner
EXPLORATION MODE (Interactive REPL):
Start exploring:
node runner.mjs --explore <url>
The browser opens and you send JSON commands via stdin:
Execute code chunk (recorded):
{"cmd": "chunk", "label": "Navigate to models", "code": "await page.click('a[href=\\"/models\\"]');"}
Inspect current page:
{"cmd": "inspect"}
Review all recorded chunks:
{"cmd": "review"}
List available flows:
{"cmd": "list-flows"}
Run a flow as a chunk:
{"cmd": "flow", "name": "browse-to-model"}
Save selected chunks as flow:
{"cmd": "save", "name": "my-flow", "keep": [1, 3, 4]}
Exit session:
{"cmd": "exit"}
Output is JSON on stdout, logs on stderr.
FLOW MODE (Run Saved Flows):
Run a flow:
node runner.mjs --run-flow <name>
List flows:
node runner.mjs --list-flows
ONE-SHOT COMMANDS:
Inspect a page:
node runner.mjs --inspect <url>
OPTIONS:
--headless Run browser without visible window
--timeout <ms> Default timeout for actions (default: 30000)
--help, -h Show this help
EXAMPLE WORKFLOW:
1. Start exploration:
node runner.mjs --explore https://example.com
2. Execute chunks (agent sends these via stdin):
{"cmd": "chunk", "label": "Click login", "code": "await page.click('button.login');"}
{"cmd": "chunk", "label": "Fill form", "code": "await page.fill('#email', 'test@example.com');"}
3. Review what was recorded:
{"cmd": "review"}
4. Save the good chunks as a flow:
{"cmd": "save", "name": "login-flow", "keep": [1, 2]}
5. Later, replay the flow:
node runner.mjs --run-flow login-flow
`);
}
// Main execution
async function main() {
const config = parseArgs();
// === REPL EXPLORATION ===
if (config.explore) {
await startExploreRepl(config.explore, { headless: config.headless });
return;
}
// === FLOW COMMANDS ===
if (config.runFlow) {
console.log(`Running flow: ${config.runFlow}`);
try {
const result = await runFlow(config.runFlow, { headless: config.headless, timeout: config.timeout });
console.log('\n--- FLOW RESULT ---');
console.log(JSON.stringify(result, null, 2));
process.exit(result.status === 'passed' ? 0 : 1);
} catch (e) {
console.error('Failed to run flow:', e.message);
process.exit(1);
}
return;
}
if (config.listFlows) {
const flows = listFlows();
console.log('\n=== SAVED FLOWS ===\n');
if (flows.length === 0) {
console.log(' No flows saved yet.');
console.log(' Use --explore to start a session and save flows.');
} else {
for (const flow of flows) {
console.log(` ${flow.name}`);
console.log(` Start URL: ${flow.startUrl}`);
console.log(` Path: ${flow.path}`);
console.log();
}
}
return;
}
// === ONE-SHOT COMMANDS ===
if (config.inspectUrl) {
console.log(`\nInspecting: ${config.inspectUrl}\n`);
const result = await inspectUrl(config.inspectUrl, { headless: config.headless });
console.log('--- PAGE INSPECTION ---');
console.log(JSON.stringify(result, null, 2));
return;
}
// No command specified
console.error('No command specified. Use --help for usage information.');
console.error('\nQuick start:');
console.error(' node runner.mjs --explore https://example.com # Start exploring');
console.error(' node runner.mjs --list-flows # See saved flows');
process.exit(1);
}
main().catch(err => {
console.error('Fatal error:', err);
process.exit(1);
});
+4 -1
View File
@@ -1,7 +1,7 @@
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
# dependencies
/node_modules
node_modules
/.pnp
.pnp.js
@@ -48,6 +48,9 @@ yarn-error.log*
/postgres*
/redis
/minio
# browser automation sessions (local exploration data)
.browser/sessions
/clickhouse*
/undefined
/.docker