This commit is contained in:
Chris Tate
2026-01-10 11:33:37 -06:00
commit 2d0520b770
12 changed files with 1576 additions and 0 deletions
+33
View File
@@ -0,0 +1,33 @@
# Dependencies
node_modules/
# Build output
dist/
# Logs
*.log
npm-debug.log*
# IDE
.idea/
.vscode/
*.swp
*.swo
# OS
.DS_Store
Thumbs.db
# Test artifacts
*.png
*.jpeg
*.jpg
# Package manager
pnpm-lock.yaml
package-lock.json
yarn.lock
# Environment
.env
.env.local
+123
View File
@@ -0,0 +1,123 @@
# veb
Headless browser automation CLI for agents and humans.
## Installation
```bash
pnpm install
npx playwright install chromium
pnpm build
```
## Usage
```bash
# Open a URL (auto-starts browser daemon)
veb open https://example.com
# Click elements
veb click "#submit-btn"
veb click "text=Sign In"
# Type into inputs
veb type "#email" hello@example.com
veb type "#search" "search query"
# Press keyboard keys
veb press Enter
veb press Tab
# Wait for things
veb wait "#loading" # wait for selector
veb wait --text "Welcome" # wait for text
veb wait 2000 # wait 2 seconds
# Take screenshots
veb screenshot page.png
veb screenshot --full page.png # full page
veb screenshot -s "#hero" # specific element
# Get accessibility snapshot (great for AI agents)
veb snapshot
# Extract HTML content
veb extract "table"
veb extract "#main"
# Evaluate JavaScript
veb eval "document.title"
veb eval "window.location.href"
# Scroll the page
veb scroll down 500
veb scroll up
veb scroll -s "#container" down
# Interact with dropdowns
veb select "#country" "US"
# Hover over elements
veb hover "#menu"
# Close browser (stops daemon)
veb close
```
## Agent Mode
Use `--json` flag for machine-readable output:
```bash
veb open https://example.com --json
# {"id":"abc123","success":true,"data":{"url":"https://example.com/","title":"Example Domain"}}
veb snapshot --json
# {"id":"def456","success":true,"data":{"snapshot":"..."}}
```
## How It Works
veb runs a background daemon that keeps the browser open between commands. The first command automatically starts the daemon. Use `veb close` to shut it down.
## Commands Reference
| Command | Description |
|---------|-------------|
| `open <url>` | Navigate to a URL |
| `click <selector>` | Click an element |
| `type <selector> <text>` | Type text into an element |
| `press <key>` | Press a keyboard key |
| `wait <selector\|text\|ms>` | Wait for condition |
| `screenshot [path]` | Take a screenshot |
| `snapshot` | Get accessibility tree |
| `extract <selector>` | Get element HTML |
| `eval <script>` | Run JavaScript |
| `scroll <dir> [amount]` | Scroll page |
| `hover <selector>` | Hover over element |
| `select <selector> <val>` | Select dropdown option |
| `close` | Close browser |
## Options
| Option | Description |
|--------|-------------|
| `--json` | Output raw JSON |
| `--full, -f` | Full page screenshot |
| `--text, -t` | Wait for text |
| `--selector, -s` | Target element |
| `--debug` | Show debug timing info |
| `--help, -h` | Show help |
## Selectors
veb supports all Playwright selectors:
- CSS: `#id`, `.class`, `div.container`
- Text: `text=Click me`, `"Click me"`
- XPath: `xpath=//button`
- Role: `role=button[name="Submit"]`
## License
MIT
Executable
+2
View File
@@ -0,0 +1,2 @@
#!/usr/bin/env node
import '../dist/index.js';
+34
View File
@@ -0,0 +1,34 @@
{
"name": "veb",
"version": "1.0.0",
"description": "Headless browser automation CLI for agents and humans",
"type": "module",
"main": "dist/index.js",
"bin": {
"veb": "./bin/veb"
},
"scripts": {
"build": "tsc",
"start": "node dist/index.js",
"dev": "tsx src/index.ts"
},
"keywords": [
"browser",
"automation",
"headless",
"playwright",
"cli",
"agent"
],
"author": "",
"license": "MIT",
"dependencies": {
"playwright": "^1.40.0",
"zod": "^3.22.4"
},
"devDependencies": {
"@types/node": "^20.10.0",
"tsx": "^4.6.0",
"typescript": "^5.3.0"
}
}
+310
View File
@@ -0,0 +1,310 @@
import type { Page } from 'playwright';
import type { BrowserManager } from './browser.js';
import type {
Command,
Response,
NavigateCommand,
ClickCommand,
TypeCommand,
PressCommand,
ScreenshotCommand,
EvaluateCommand,
WaitCommand,
ScrollCommand,
SelectCommand,
HoverCommand,
ContentCommand,
NavigateData,
ScreenshotData,
EvaluateData,
ContentData,
} from './types.js';
import { successResponse, errorResponse } from './protocol.js';
// Snapshot response type
interface SnapshotData {
snapshot: string;
}
/**
* Execute a command and return a response
*/
export async function executeCommand(
command: Command,
browser: BrowserManager
): Promise<Response> {
try {
switch (command.action) {
case 'launch':
return await handleLaunch(command, browser);
case 'navigate':
return await handleNavigate(command, browser);
case 'click':
return await handleClick(command, browser);
case 'type':
return await handleType(command, browser);
case 'press':
return await handlePress(command, browser);
case 'screenshot':
return await handleScreenshot(command, browser);
case 'snapshot':
return await handleSnapshot(command, browser);
case 'evaluate':
return await handleEvaluate(command, browser);
case 'wait':
return await handleWait(command, browser);
case 'scroll':
return await handleScroll(command, browser);
case 'select':
return await handleSelect(command, browser);
case 'hover':
return await handleHover(command, browser);
case 'content':
return await handleContent(command, browser);
case 'close':
return await handleClose(command, browser);
default: {
// TypeScript narrows to never here, but we handle it for safety
const unknownCommand = command as { id: string; action: string };
return errorResponse(unknownCommand.id, `Unknown action: ${unknownCommand.action}`);
}
}
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
return errorResponse(command.id, message);
}
}
async function handleLaunch(
command: Command & { action: 'launch' },
browser: BrowserManager
): Promise<Response> {
await browser.launch(command);
return successResponse(command.id, { launched: true });
}
async function handleNavigate(
command: NavigateCommand,
browser: BrowserManager
): Promise<Response<NavigateData>> {
const page = browser.getPage();
await page.goto(command.url, {
waitUntil: command.waitUntil ?? 'load',
});
return successResponse(command.id, {
url: page.url(),
title: await page.title(),
});
}
async function handleClick(
command: ClickCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
await page.click(command.selector, {
button: command.button,
clickCount: command.clickCount,
delay: command.delay,
});
return successResponse(command.id, { clicked: true });
}
async function handleType(
command: TypeCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
if (command.clear) {
await page.fill(command.selector, '');
}
await page.type(command.selector, command.text, {
delay: command.delay,
});
return successResponse(command.id, { typed: true });
}
async function handlePress(
command: PressCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
if (command.selector) {
await page.press(command.selector, command.key);
} else {
await page.keyboard.press(command.key);
}
return successResponse(command.id, { pressed: true });
}
async function handleScreenshot(
command: ScreenshotCommand,
browser: BrowserManager
): Promise<Response<ScreenshotData>> {
const page = browser.getPage();
const options: Parameters<Page['screenshot']>[0] = {
fullPage: command.fullPage,
type: command.format ?? 'png',
};
if (command.format === 'jpeg' && command.quality !== undefined) {
options.quality = command.quality;
}
let target: Page | ReturnType<Page['locator']> = page;
if (command.selector) {
target = page.locator(command.selector);
}
if (command.path) {
await target.screenshot({ ...options, path: command.path });
return successResponse(command.id, { path: command.path });
} else {
const buffer = await target.screenshot(options);
return successResponse(command.id, { base64: buffer.toString('base64') });
}
}
async function handleSnapshot(
command: Command & { action: 'snapshot' },
browser: BrowserManager
): Promise<Response<SnapshotData>> {
const page = browser.getPage();
// Use ariaSnapshot which returns a string representation of the accessibility tree
const snapshot = await page.locator(':root').ariaSnapshot();
return successResponse(command.id, {
snapshot: snapshot ?? 'Empty page',
});
}
async function handleEvaluate(
command: EvaluateCommand,
browser: BrowserManager
): Promise<Response<EvaluateData>> {
const page = browser.getPage();
// Evaluate the script directly as a string expression
const result = await page.evaluate(command.script);
return successResponse(command.id, { result });
}
async function handleWait(
command: WaitCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
if (command.selector) {
await page.waitForSelector(command.selector, {
state: command.state ?? 'visible',
timeout: command.timeout,
});
} else if (command.timeout) {
await page.waitForTimeout(command.timeout);
} else {
// Default: wait for load state
await page.waitForLoadState('load');
}
return successResponse(command.id, { waited: true });
}
async function handleScroll(
command: ScrollCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
if (command.selector) {
const element = page.locator(command.selector);
await element.scrollIntoViewIfNeeded();
if (command.x !== undefined || command.y !== undefined) {
await element.evaluate((el, { x, y }) => {
el.scrollBy(x ?? 0, y ?? 0);
}, { x: command.x, y: command.y });
}
} else {
// Scroll the page
let deltaX = command.x ?? 0;
let deltaY = command.y ?? 0;
if (command.direction) {
const amount = command.amount ?? 100;
switch (command.direction) {
case 'up':
deltaY = -amount;
break;
case 'down':
deltaY = amount;
break;
case 'left':
deltaX = -amount;
break;
case 'right':
deltaX = amount;
break;
}
}
await page.evaluate(`window.scrollBy(${deltaX}, ${deltaY})`);
}
return successResponse(command.id, { scrolled: true });
}
async function handleSelect(
command: SelectCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
const values = Array.isArray(command.values) ? command.values : [command.values];
await page.selectOption(command.selector, values);
return successResponse(command.id, { selected: values });
}
async function handleHover(
command: HoverCommand,
browser: BrowserManager
): Promise<Response> {
const page = browser.getPage();
await page.hover(command.selector);
return successResponse(command.id, { hovered: true });
}
async function handleContent(
command: ContentCommand,
browser: BrowserManager
): Promise<Response<ContentData>> {
const page = browser.getPage();
let html: string;
if (command.selector) {
html = await page.locator(command.selector).innerHTML();
} else {
html = await page.content();
}
return successResponse(command.id, { html });
}
async function handleClose(
command: Command & { action: 'close' },
browser: BrowserManager
): Promise<Response> {
await browser.close();
return successResponse(command.id, { closed: true });
}
+95
View File
@@ -0,0 +1,95 @@
import { chromium, firefox, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
import type { BrowserState, LaunchCommand } from './types.js';
/**
* Manages the Playwright browser lifecycle
*/
export class BrowserManager {
private state: BrowserState = {
browser: null,
context: null,
page: null,
};
/**
* Check if browser is launched
*/
isLaunched(): boolean {
return this.state.browser !== null;
}
/**
* Get the current page, throws if not launched
*/
getPage(): Page {
if (!this.state.page) {
throw new Error('Browser not launched. Call launch first.');
}
return this.state.page;
}
/**
* Get the current browser instance
*/
getBrowser(): Browser | null {
return this.state.browser;
}
/**
* Get the current context
*/
getContext(): BrowserContext | null {
return this.state.context;
}
/**
* Launch the browser with the specified options
*/
async launch(options: LaunchCommand): Promise<void> {
// Close existing browser if any
if (this.state.browser) {
await this.close();
}
// Select browser type
const browserType = options.browser ?? 'chromium';
const launcher = browserType === 'firefox'
? firefox
: browserType === 'webkit'
? webkit
: chromium;
// Launch browser
this.state.browser = await launcher.launch({
headless: options.headless ?? true,
});
// Create context with viewport
this.state.context = await this.state.browser.newContext({
viewport: options.viewport ?? { width: 1280, height: 720 },
});
// Create initial page
this.state.page = await this.state.context.newPage();
}
/**
* Close the browser and clean up
*/
async close(): Promise<void> {
if (this.state.page) {
await this.state.page.close().catch(() => {});
this.state.page = null;
}
if (this.state.context) {
await this.state.context.close().catch(() => {});
this.state.context = null;
}
if (this.state.browser) {
await this.state.browser.close().catch(() => {});
this.state.browser = null;
}
}
}
+147
View File
@@ -0,0 +1,147 @@
import * as net from 'net';
import { spawn } from 'child_process';
import { fileURLToPath } from 'url';
import * as path from 'path';
import * as fs from 'fs';
import { getSocketPath, isDaemonRunning } from './daemon.js';
import type { Response } from './types.js';
const __dirname = path.dirname(fileURLToPath(import.meta.url));
let DEBUG = false;
export function setDebug(enabled: boolean): void {
DEBUG = enabled;
}
function debug(...args: unknown[]): void {
if (DEBUG) {
console.error('[debug]', ...args);
}
}
/**
* Wait for socket to exist
*/
async function waitForSocket(maxAttempts = 30): Promise<boolean> {
const socketPath = getSocketPath();
debug('Waiting for socket at', socketPath);
for (let i = 0; i < maxAttempts; i++) {
if (fs.existsSync(socketPath)) {
debug('Socket found after', i * 100, 'ms');
return true;
}
await new Promise(r => setTimeout(r, 100));
}
debug('Socket not found after', maxAttempts * 100, 'ms');
return false;
}
/**
* Ensure daemon is running, start if not
*/
export async function ensureDaemon(): Promise<void> {
debug('Checking if daemon is running...');
if (isDaemonRunning()) {
debug('Daemon already running');
return;
}
debug('Starting daemon...');
const daemonPath = path.join(__dirname, 'daemon.js');
const child = spawn(process.execPath, [daemonPath], {
detached: true,
stdio: 'ignore',
env: { ...process.env, VEB_DAEMON: '1' },
});
child.unref();
// Wait for socket to be created
const ready = await waitForSocket();
if (!ready) {
throw new Error('Failed to start daemon');
}
debug('Daemon started');
}
/**
* Send a command to the daemon
*/
export async function sendCommand(command: Record<string, unknown>): Promise<Response> {
const socketPath = getSocketPath();
debug('Sending command:', JSON.stringify(command));
return new Promise((resolve, reject) => {
let resolved = false;
let buffer = '';
const startTime = Date.now();
const socket = net.createConnection(socketPath);
socket.on('connect', () => {
debug('Connected to daemon, sending command...');
socket.write(JSON.stringify(command) + '\n');
});
socket.on('data', (data) => {
buffer += data.toString();
debug('Received data:', buffer.length, 'bytes');
// Try to parse complete JSON from buffer
const newlineIdx = buffer.indexOf('\n');
if (newlineIdx !== -1) {
const jsonStr = buffer.substring(0, newlineIdx);
try {
const response = JSON.parse(jsonStr) as Response;
debug('Response received in', Date.now() - startTime, 'ms');
resolved = true;
socket.end();
resolve(response);
} catch (e) {
debug('JSON parse error:', e);
}
}
});
socket.on('error', (err) => {
debug('Socket error:', err.message);
if (!resolved) {
reject(new Error(`Connection error: ${err.message}`));
}
});
socket.on('close', () => {
debug('Socket closed, resolved:', resolved, 'buffer:', buffer.length);
if (!resolved && buffer.trim()) {
try {
const response = JSON.parse(buffer.trim()) as Response;
resolve(response);
} catch {
reject(new Error('Invalid response from daemon'));
}
} else if (!resolved) {
reject(new Error('Connection closed without response'));
}
});
// Timeout after 60 seconds
setTimeout(() => {
if (!resolved) {
debug('Command timeout after 60s');
socket.destroy();
reject(new Error('Command timeout'));
}
}, 60000);
});
}
/**
* Send a command, ensuring daemon is running first
*/
export async function send(command: Record<string, unknown>): Promise<Response> {
const startTime = Date.now();
await ensureDaemon();
debug('ensureDaemon took', Date.now() - startTime, 'ms');
return sendCommand(command);
}
+161
View File
@@ -0,0 +1,161 @@
import * as net from 'net';
import * as fs from 'fs';
import * as path from 'path';
import * as os from 'os';
import { BrowserManager } from './browser.js';
import { parseCommand, serializeResponse, errorResponse } from './protocol.js';
import { executeCommand } from './actions.js';
const SOCKET_PATH = path.join(os.tmpdir(), 'veb.sock');
const PID_FILE = path.join(os.tmpdir(), 'veb.pid');
/**
* Get the socket path
*/
export function getSocketPath(): string {
return SOCKET_PATH;
}
/**
* Get the PID file path
*/
export function getPidFile(): string {
return PID_FILE;
}
/**
* Check if daemon is running
*/
export function isDaemonRunning(): boolean {
if (!fs.existsSync(PID_FILE)) return false;
try {
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
// Check if process exists
process.kill(pid, 0);
return true;
} catch {
// Process doesn't exist, clean up stale files
cleanupSocket();
return false;
}
}
/**
* Clean up socket and PID file
*/
export function cleanupSocket(): void {
try {
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
} catch {
// Ignore cleanup errors
}
}
/**
* Start the daemon server
*/
export async function startDaemon(): Promise<void> {
// Clean up any stale socket
cleanupSocket();
const browser = new BrowserManager();
let shuttingDown = false;
const server = net.createServer((socket) => {
let buffer = '';
socket.on('data', async (data) => {
buffer += data.toString();
// Process complete lines
while (buffer.includes('\n')) {
const newlineIdx = buffer.indexOf('\n');
const line = buffer.substring(0, newlineIdx);
buffer = buffer.substring(newlineIdx + 1);
if (!line.trim()) continue;
try {
const parseResult = parseCommand(line);
if (!parseResult.success) {
const resp = errorResponse(parseResult.id ?? 'unknown', parseResult.error);
socket.write(serializeResponse(resp) + '\n');
continue;
}
// Auto-launch browser if not already launched and this isn't a launch command
if (!browser.isLaunched() && parseResult.command.action !== 'launch' && parseResult.command.action !== 'close') {
await browser.launch({ id: 'auto', action: 'launch', headless: true });
}
// Handle close command specially
if (parseResult.command.action === 'close') {
const response = await executeCommand(parseResult.command, browser);
socket.write(serializeResponse(response) + '\n');
if (!shuttingDown) {
shuttingDown = true;
setTimeout(() => {
server.close();
cleanupSocket();
process.exit(0);
}, 100);
}
return;
}
const response = await executeCommand(parseResult.command, browser);
socket.write(serializeResponse(response) + '\n');
} catch (err) {
const message = err instanceof Error ? err.message : String(err);
socket.write(serializeResponse(errorResponse('error', message)) + '\n');
}
}
});
socket.on('error', () => {
// Client disconnected, ignore
});
});
// Write PID file before listening
fs.writeFileSync(PID_FILE, process.pid.toString());
server.listen(SOCKET_PATH, () => {
// Daemon is ready
});
server.on('error', (err) => {
console.error('Server error:', err);
cleanupSocket();
process.exit(1);
});
// Handle shutdown signals
const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
await browser.close();
server.close();
cleanupSocket();
process.exit(0);
};
process.on('SIGINT', shutdown);
process.on('SIGTERM', shutdown);
// Keep process alive
process.stdin.resume();
}
// Run daemon if this is the entry point
if (process.argv[1]?.endsWith('daemon.js') || process.env.VEB_DAEMON === '1') {
startDaemon().catch((err) => {
console.error('Daemon error:', err);
cleanupSocket();
process.exit(1);
});
}
+316
View File
@@ -0,0 +1,316 @@
#!/usr/bin/env node
import { send, setDebug } from './client.js';
import type { Response } from './types.js';
// ANSI colors
const colors = {
reset: '\x1b[0m',
bold: '\x1b[1m',
dim: '\x1b[2m',
red: '\x1b[31m',
green: '\x1b[32m',
yellow: '\x1b[33m',
blue: '\x1b[34m',
magenta: '\x1b[35m',
cyan: '\x1b[36m',
};
const c = (color: keyof typeof colors, text: string) => `${colors[color]}${text}${colors.reset}`;
function printHelp(): void {
console.log(`
${c('bold', 'veb')} - headless browser automation for agents and humans
${c('yellow', 'Usage:')}
veb <command> [options]
${c('yellow', 'Commands:')}
${c('cyan', 'open')} <url> Open a URL in the browser
${c('cyan', 'click')} <selector> Click an element
${c('cyan', 'type')} <selector> <text> Type text into an element
${c('cyan', 'press')} <key> Press a keyboard key
${c('cyan', 'wait')} <selector|text|ms> Wait for element, text, or duration
${c('cyan', 'screenshot')} [path] Take a screenshot
${c('cyan', 'snapshot')} Get accessibility tree (for agents)
${c('cyan', 'extract')} <selector> Extract element content
${c('cyan', 'eval')} <script> Evaluate JavaScript
${c('cyan', 'scroll')} <direction> [amount] Scroll the page
${c('cyan', 'hover')} <selector> Hover over an element
${c('cyan', 'select')} <selector> <value> Select dropdown option
${c('cyan', 'close')} Close browser and stop daemon
${c('yellow', 'Options:')}
--json Output raw JSON (for agents)
--selector, -s <sel> Target specific element
--text, -t Wait for text instead of selector
--full, -f Full page screenshot
--debug Show debug output
--help, -h Show help
${c('yellow', 'Examples:')}
veb open https://example.com
veb click "#submit-btn"
veb type "#email" "hello@example.com"
veb wait --text "Welcome"
veb wait 2000
veb screenshot --full page.png
veb extract "table" --json
veb eval "document.title"
veb scroll down 500
`);
}
function genId(): string {
return Math.random().toString(36).slice(2, 10);
}
function printResponse(response: Response, jsonMode: boolean): void {
if (jsonMode) {
console.log(JSON.stringify(response));
return;
}
if (!response.success) {
console.error(c('red', '✗ Error:'), response.error);
process.exit(1);
}
const data = response.data as Record<string, unknown>;
// Pretty print based on data type
if (data.url && data.title) {
console.log(c('green', '✓'), c('bold', data.title as string));
console.log(c('dim', ` ${data.url}`));
} else if (data.html) {
console.log(data.html);
} else if (data.snapshot) {
console.log(data.snapshot);
} else if (data.result !== undefined) {
const result = data.result;
if (typeof result === 'object') {
console.log(JSON.stringify(result, null, 2));
} else {
console.log(result);
}
} else if (data.base64) {
console.log(c('green', '✓'), 'Screenshot captured (base64)');
console.log(c('dim', ` ${(data.base64 as string).length} bytes`));
} else if (data.path) {
console.log(c('green', '✓'), `Saved to ${data.path}`);
} else if (data.clicked || data.typed || data.pressed || data.hovered || data.scrolled || data.selected || data.waited) {
console.log(c('green', '✓'), 'Done');
} else if (data.launched) {
console.log(c('green', '✓'), 'Browser launched');
} else if (data.closed) {
console.log(c('green', '✓'), 'Browser closed');
} else {
console.log(c('green', '✓'), JSON.stringify(data));
}
}
async function main(): Promise<void> {
const args = process.argv.slice(2);
// Enable debug mode early
const debugMode = args.includes('--debug');
if (debugMode) {
setDebug(true);
}
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
printHelp();
process.exit(0);
}
const jsonMode = args.includes('--json');
const fullPage = args.includes('--full') || args.includes('-f');
const textMode = args.includes('--text') || args.includes('-t');
// Remove flag args and their values
const cleanArgs = args.filter((a, i) => {
if (a.startsWith('-')) return false;
// Check if previous arg was a flag that takes a value
const prev = args[i - 1];
if (prev === '--selector' || prev === '-s') return false;
return true;
});
const command = cleanArgs[0];
// Find --selector value
let selectorOverride: string | undefined;
const sIdx = args.findIndex(a => a === '--selector' || a === '-s');
if (sIdx !== -1 && args[sIdx + 1]) {
selectorOverride = args[sIdx + 1];
}
const id = genId();
let cmd: Record<string, unknown>;
switch (command) {
case 'open':
case 'goto':
case 'navigate': {
const url = cleanArgs[1];
if (!url) {
console.error(c('red', 'Error:'), 'URL required');
process.exit(1);
}
// Auto-add https if missing
const fullUrl = url.startsWith('http') ? url : `https://${url}`;
cmd = { id, action: 'navigate', url: fullUrl };
break;
}
case 'click': {
const selector = cleanArgs[1];
if (!selector) {
console.error(c('red', 'Error:'), 'Selector required');
process.exit(1);
}
cmd = { id, action: 'click', selector };
break;
}
case 'type': {
const selector = cleanArgs[1];
const text = cleanArgs.slice(2).join(' ');
if (!selector || !text) {
console.error(c('red', 'Error:'), 'Selector and text required');
process.exit(1);
}
cmd = { id, action: 'type', selector, text, clear: true };
break;
}
case 'press': {
const key = cleanArgs[1];
if (!key) {
console.error(c('red', 'Error:'), 'Key required');
process.exit(1);
}
cmd = { id, action: 'press', key, selector: selectorOverride };
break;
}
case 'wait': {
const target = cleanArgs[1];
if (!target) {
console.error(c('red', 'Error:'), 'Selector, text, or milliseconds required');
process.exit(1);
}
// Check if it's a number (milliseconds)
const ms = parseInt(target, 10);
if (!isNaN(ms)) {
cmd = { id, action: 'wait', timeout: ms };
} else if (textMode) {
// Wait for text - use evaluate to check for text
cmd = { id, action: 'wait', selector: `text=${target}` };
} else {
cmd = { id, action: 'wait', selector: target };
}
break;
}
case 'screenshot':
case 'ss': {
const pathArg = cleanArgs[1];
cmd = {
id,
action: 'screenshot',
path: pathArg,
fullPage,
selector: selectorOverride,
};
break;
}
case 'snapshot':
case 'aria':
case 'a11y': {
cmd = { id, action: 'snapshot' };
break;
}
case 'extract':
case 'html':
case 'content': {
const selector = cleanArgs[1] || selectorOverride;
cmd = { id, action: 'content', selector };
break;
}
case 'eval':
case 'js': {
const script = cleanArgs.slice(1).join(' ');
if (!script) {
console.error(c('red', 'Error:'), 'Script required');
process.exit(1);
}
cmd = { id, action: 'evaluate', script };
break;
}
case 'scroll': {
const dirOrAmount = cleanArgs[1];
const amount = parseInt(cleanArgs[2], 10) || 300;
if (['up', 'down', 'left', 'right'].includes(dirOrAmount)) {
cmd = { id, action: 'scroll', direction: dirOrAmount, amount, selector: selectorOverride };
} else {
const y = parseInt(dirOrAmount, 10) || 300;
cmd = { id, action: 'scroll', y, selector: selectorOverride };
}
break;
}
case 'hover': {
const selector = cleanArgs[1];
if (!selector) {
console.error(c('red', 'Error:'), 'Selector required');
process.exit(1);
}
cmd = { id, action: 'hover', selector };
break;
}
case 'select': {
const selector = cleanArgs[1];
const value = cleanArgs[2];
if (!selector || !value) {
console.error(c('red', 'Error:'), 'Selector and value required');
process.exit(1);
}
cmd = { id, action: 'select', selector, values: value };
break;
}
case 'close':
case 'quit':
case 'exit': {
cmd = { id, action: 'close' };
break;
}
default:
console.error(c('red', 'Error:'), `Unknown command: ${command}`);
console.error(c('dim', 'Run veb --help for usage'));
process.exit(1);
}
try {
const response = await send(cmd);
printResponse(response, jsonMode);
process.exit(0);
} catch (error) {
const message = error instanceof Error ? error.message : String(error);
if (jsonMode) {
console.log(JSON.stringify({ id, success: false, error: message }));
} else {
console.error(c('red', '✗ Error:'), message);
}
process.exit(1);
}
}
main();
+176
View File
@@ -0,0 +1,176 @@
import { z } from 'zod';
import type { Command, Response } from './types.js';
// Base schema for all commands
const baseCommandSchema = z.object({
id: z.string(),
action: z.string(),
});
// Individual action schemas
const launchSchema = baseCommandSchema.extend({
action: z.literal('launch'),
headless: z.boolean().optional(),
viewport: z.object({
width: z.number().positive(),
height: z.number().positive(),
}).optional(),
browser: z.enum(['chromium', 'firefox', 'webkit']).optional(),
});
const navigateSchema = baseCommandSchema.extend({
action: z.literal('navigate'),
url: z.string().min(1),
waitUntil: z.enum(['load', 'domcontentloaded', 'networkidle']).optional(),
});
const clickSchema = baseCommandSchema.extend({
action: z.literal('click'),
selector: z.string().min(1),
button: z.enum(['left', 'right', 'middle']).optional(),
clickCount: z.number().positive().optional(),
delay: z.number().nonnegative().optional(),
});
const typeSchema = baseCommandSchema.extend({
action: z.literal('type'),
selector: z.string().min(1),
text: z.string(),
delay: z.number().nonnegative().optional(),
clear: z.boolean().optional(),
});
const pressSchema = baseCommandSchema.extend({
action: z.literal('press'),
key: z.string().min(1),
selector: z.string().min(1).optional(),
});
const screenshotSchema = baseCommandSchema.extend({
action: z.literal('screenshot'),
path: z.string().optional(),
fullPage: z.boolean().optional(),
selector: z.string().min(1).optional(),
format: z.enum(['png', 'jpeg']).optional(),
quality: z.number().min(0).max(100).optional(),
});
const snapshotSchema = baseCommandSchema.extend({
action: z.literal('snapshot'),
});
const evaluateSchema = baseCommandSchema.extend({
action: z.literal('evaluate'),
script: z.string().min(1),
args: z.array(z.unknown()).optional(),
});
const waitSchema = baseCommandSchema.extend({
action: z.literal('wait'),
selector: z.string().min(1).optional(),
timeout: z.number().positive().optional(),
state: z.enum(['attached', 'detached', 'visible', 'hidden']).optional(),
});
const scrollSchema = baseCommandSchema.extend({
action: z.literal('scroll'),
selector: z.string().min(1).optional(),
x: z.number().optional(),
y: z.number().optional(),
direction: z.enum(['up', 'down', 'left', 'right']).optional(),
amount: z.number().positive().optional(),
});
const selectSchema = baseCommandSchema.extend({
action: z.literal('select'),
selector: z.string().min(1),
values: z.union([z.string(), z.array(z.string())]),
});
const hoverSchema = baseCommandSchema.extend({
action: z.literal('hover'),
selector: z.string().min(1),
});
const contentSchema = baseCommandSchema.extend({
action: z.literal('content'),
selector: z.string().min(1).optional(),
});
const closeSchema = baseCommandSchema.extend({
action: z.literal('close'),
});
// Union schema for all commands
const commandSchema = z.discriminatedUnion('action', [
launchSchema,
navigateSchema,
clickSchema,
typeSchema,
pressSchema,
screenshotSchema,
snapshotSchema,
evaluateSchema,
waitSchema,
scrollSchema,
selectSchema,
hoverSchema,
contentSchema,
closeSchema,
]);
// Parse result type
export type ParseResult =
| { success: true; command: Command }
| { success: false; error: string; id?: string };
/**
* Parse a JSON string into a validated command
*/
export function parseCommand(input: string): ParseResult {
// First, try to parse JSON
let json: unknown;
try {
json = JSON.parse(input);
} catch {
return { success: false, error: 'Invalid JSON' };
}
// Extract id for error responses if possible
const id = typeof json === 'object' && json !== null && 'id' in json
? String((json as { id: unknown }).id)
: undefined;
// Validate against schema
const result = commandSchema.safeParse(json);
if (!result.success) {
const errors = result.error.errors
.map(e => `${e.path.join('.')}: ${e.message}`)
.join(', ');
return { success: false, error: `Validation error: ${errors}`, id };
}
return { success: true, command: result.data as Command };
}
/**
* Create a success response
*/
export function successResponse<T>(id: string, data: T): Response<T> {
return { id, success: true, data };
}
/**
* Create an error response
*/
export function errorResponse(id: string, error: string): Response {
return { id, success: false, error };
}
/**
* Serialize a response to JSON string
*/
export function serializeResponse(response: Response): string {
return JSON.stringify(response);
}
+160
View File
@@ -0,0 +1,160 @@
import type { Page, Browser, BrowserContext } from 'playwright';
// Base command structure
export interface BaseCommand {
id: string;
action: string;
}
// Action-specific command types
export interface LaunchCommand extends BaseCommand {
action: 'launch';
headless?: boolean;
viewport?: { width: number; height: number };
browser?: 'chromium' | 'firefox' | 'webkit';
}
export interface NavigateCommand extends BaseCommand {
action: 'navigate';
url: string;
waitUntil?: 'load' | 'domcontentloaded' | 'networkidle';
}
export interface ClickCommand extends BaseCommand {
action: 'click';
selector: string;
button?: 'left' | 'right' | 'middle';
clickCount?: number;
delay?: number;
}
export interface TypeCommand extends BaseCommand {
action: 'type';
selector: string;
text: string;
delay?: number;
clear?: boolean;
}
export interface PressCommand extends BaseCommand {
action: 'press';
key: string;
selector?: string;
}
export interface ScreenshotCommand extends BaseCommand {
action: 'screenshot';
path?: string;
fullPage?: boolean;
selector?: string;
format?: 'png' | 'jpeg';
quality?: number;
}
export interface SnapshotCommand extends BaseCommand {
action: 'snapshot';
}
export interface EvaluateCommand extends BaseCommand {
action: 'evaluate';
script: string;
args?: unknown[];
}
export interface WaitCommand extends BaseCommand {
action: 'wait';
selector?: string;
timeout?: number;
state?: 'attached' | 'detached' | 'visible' | 'hidden';
}
export interface ScrollCommand extends BaseCommand {
action: 'scroll';
selector?: string;
x?: number;
y?: number;
direction?: 'up' | 'down' | 'left' | 'right';
amount?: number;
}
export interface SelectCommand extends BaseCommand {
action: 'select';
selector: string;
values: string | string[];
}
export interface HoverCommand extends BaseCommand {
action: 'hover';
selector: string;
}
export interface ContentCommand extends BaseCommand {
action: 'content';
selector?: string;
}
export interface CloseCommand extends BaseCommand {
action: 'close';
}
// Union of all command types
export type Command =
| LaunchCommand
| NavigateCommand
| ClickCommand
| TypeCommand
| PressCommand
| ScreenshotCommand
| SnapshotCommand
| EvaluateCommand
| WaitCommand
| ScrollCommand
| SelectCommand
| HoverCommand
| ContentCommand
| CloseCommand;
// Response types
export interface SuccessResponse<T = unknown> {
id: string;
success: true;
data: T;
}
export interface ErrorResponse {
id: string;
success: false;
error: string;
}
export type Response<T = unknown> = SuccessResponse<T> | ErrorResponse;
// Data types for specific responses
export interface NavigateData {
url: string;
title: string;
}
export interface ScreenshotData {
path?: string;
base64?: string;
}
export interface SnapshotData {
snapshot: string;
}
export interface EvaluateData {
result: unknown;
}
export interface ContentData {
html: string;
}
// Browser state
export interface BrowserState {
browser: Browser | null;
context: BrowserContext | null;
page: Page | null;
}
+19
View File
@@ -0,0 +1,19 @@
{
"compilerOptions": {
"target": "ES2022",
"module": "NodeNext",
"moduleResolution": "NodeNext",
"lib": ["ES2022"],
"outDir": "./dist",
"rootDir": "./src",
"strict": true,
"esModuleInterop": true,
"skipLibCheck": true,
"forceConsistentCasingInFileNames": true,
"declaration": true,
"declarationMap": true,
"sourceMap": true
},
"include": ["src/**/*"],
"exclude": ["node_modules", "dist"]
}