session
This commit is contained in:
@@ -60,6 +60,22 @@ veb select "#country" "US"
|
||||
# Hover over elements
|
||||
veb hover "#menu"
|
||||
|
||||
# Tab management
|
||||
veb tab new # Open new tab
|
||||
veb tab list # List all tabs
|
||||
veb tab 0 # Switch to tab 0
|
||||
veb tab close # Close current tab
|
||||
veb tab close 1 # Close tab 1
|
||||
|
||||
# Window management
|
||||
veb window new # Open new window
|
||||
|
||||
# Session management (isolate multiple agents)
|
||||
veb --session agent1 open example.com
|
||||
veb --session agent2 open google.com
|
||||
veb session list # List active sessions
|
||||
VEB_SESSION=agent1 veb eval "document.title"
|
||||
|
||||
# Close browser (stops daemon)
|
||||
veb close
|
||||
```
|
||||
@@ -96,12 +112,43 @@ veb runs a background daemon that keeps the browser open between commands. The f
|
||||
| `scroll <dir> [amount]` | Scroll page |
|
||||
| `hover <selector>` | Hover over element |
|
||||
| `select <selector> <val>` | Select dropdown option |
|
||||
| `tab new` | Open new tab |
|
||||
| `tab list` | List all tabs |
|
||||
| `tab <index>` | Switch to tab |
|
||||
| `tab close [index]` | Close tab |
|
||||
| `window new` | Open new window |
|
||||
| `session` | Show current session |
|
||||
| `session list` | List active sessions |
|
||||
| `close` | Close browser |
|
||||
|
||||
## Sessions
|
||||
|
||||
Sessions allow multiple agents to use veb simultaneously without interfering with each other. Each session runs its own isolated browser instance.
|
||||
|
||||
```bash
|
||||
# Using --session flag
|
||||
veb --session agent1 open https://site-a.com
|
||||
veb --session agent2 open https://site-b.com
|
||||
|
||||
# Using environment variable
|
||||
export VEB_SESSION=agent1
|
||||
veb open https://example.com
|
||||
veb click "#button"
|
||||
|
||||
# List all running sessions
|
||||
veb session list
|
||||
|
||||
# Close a specific session
|
||||
veb --session agent1 close
|
||||
```
|
||||
|
||||
Sessions are identified by name. If no session is specified, the "default" session is used.
|
||||
|
||||
## Options
|
||||
|
||||
| Option | Description |
|
||||
|--------|-------------|
|
||||
| `--session <name>` | Use isolated browser session |
|
||||
| `--json` | Output raw JSON |
|
||||
| `--full, -f` | Full page screenshot |
|
||||
| `--text, -t` | Wait for text |
|
||||
|
||||
@@ -14,10 +14,17 @@ import type {
|
||||
SelectCommand,
|
||||
HoverCommand,
|
||||
ContentCommand,
|
||||
TabSwitchCommand,
|
||||
TabCloseCommand,
|
||||
WindowNewCommand,
|
||||
NavigateData,
|
||||
ScreenshotData,
|
||||
EvaluateData,
|
||||
ContentData,
|
||||
TabListData,
|
||||
TabNewData,
|
||||
TabSwitchData,
|
||||
TabCloseData,
|
||||
} from './types.js';
|
||||
import { successResponse, errorResponse } from './protocol.js';
|
||||
|
||||
@@ -63,6 +70,16 @@ export async function executeCommand(
|
||||
return await handleContent(command, browser);
|
||||
case 'close':
|
||||
return await handleClose(command, browser);
|
||||
case 'tab_new':
|
||||
return await handleTabNew(command, browser);
|
||||
case 'tab_list':
|
||||
return await handleTabList(command, browser);
|
||||
case 'tab_switch':
|
||||
return await handleTabSwitch(command, browser);
|
||||
case 'tab_close':
|
||||
return await handleTabClose(command, browser);
|
||||
case 'window_new':
|
||||
return await handleWindowNew(command, browser);
|
||||
default: {
|
||||
// TypeScript narrows to never here, but we handle it for safety
|
||||
const unknownCommand = command as { id: string; action: string };
|
||||
@@ -308,3 +325,50 @@ async function handleClose(
|
||||
await browser.close();
|
||||
return successResponse(command.id, { closed: true });
|
||||
}
|
||||
|
||||
async function handleTabNew(
|
||||
command: Command & { action: 'tab_new' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response<TabNewData>> {
|
||||
const result = await browser.newTab();
|
||||
return successResponse(command.id, result);
|
||||
}
|
||||
|
||||
async function handleTabList(
|
||||
command: Command & { action: 'tab_list' },
|
||||
browser: BrowserManager
|
||||
): Promise<Response<TabListData>> {
|
||||
const tabs = await browser.listTabs();
|
||||
return successResponse(command.id, {
|
||||
tabs,
|
||||
active: browser.getActiveIndex(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTabSwitch(
|
||||
command: TabSwitchCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<TabSwitchData>> {
|
||||
const result = browser.switchTo(command.index);
|
||||
const page = browser.getPage();
|
||||
return successResponse(command.id, {
|
||||
...result,
|
||||
title: await page.title(),
|
||||
});
|
||||
}
|
||||
|
||||
async function handleTabClose(
|
||||
command: TabCloseCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<TabCloseData>> {
|
||||
const result = await browser.closeTab(command.index);
|
||||
return successResponse(command.id, result);
|
||||
}
|
||||
|
||||
async function handleWindowNew(
|
||||
command: WindowNewCommand,
|
||||
browser: BrowserManager
|
||||
): Promise<Response<TabNewData>> {
|
||||
const result = await browser.newWindow(command.viewport);
|
||||
return successResponse(command.id, result);
|
||||
}
|
||||
|
||||
+143
-35
@@ -1,45 +1,51 @@
|
||||
import { chromium, firefox, webkit, type Browser, type BrowserContext, type Page } from 'playwright';
|
||||
import type { BrowserState, LaunchCommand } from './types.js';
|
||||
import type { LaunchCommand } from './types.js';
|
||||
|
||||
/**
|
||||
* Manages the Playwright browser lifecycle
|
||||
* Manages the Playwright browser lifecycle with multiple tabs/windows
|
||||
*/
|
||||
export class BrowserManager {
|
||||
private state: BrowserState = {
|
||||
browser: null,
|
||||
context: null,
|
||||
page: null,
|
||||
};
|
||||
private browser: Browser | null = null;
|
||||
private contexts: BrowserContext[] = [];
|
||||
private pages: Page[] = [];
|
||||
private activePageIndex: number = 0;
|
||||
|
||||
/**
|
||||
* Check if browser is launched
|
||||
*/
|
||||
isLaunched(): boolean {
|
||||
return this.state.browser !== null;
|
||||
return this.browser !== null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current page, throws if not launched
|
||||
* Get the current active page, throws if not launched
|
||||
*/
|
||||
getPage(): Page {
|
||||
if (!this.state.page) {
|
||||
if (this.pages.length === 0) {
|
||||
throw new Error('Browser not launched. Call launch first.');
|
||||
}
|
||||
return this.state.page;
|
||||
return this.pages[this.activePageIndex];
|
||||
}
|
||||
|
||||
/**
|
||||
* Get all pages
|
||||
*/
|
||||
getPages(): Page[] {
|
||||
return this.pages;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get current page index
|
||||
*/
|
||||
getActiveIndex(): number {
|
||||
return this.activePageIndex;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current browser instance
|
||||
*/
|
||||
getBrowser(): Browser | null {
|
||||
return this.state.browser;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the current context
|
||||
*/
|
||||
getContext(): BrowserContext | null {
|
||||
return this.state.context;
|
||||
return this.browser;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -47,7 +53,7 @@ export class BrowserManager {
|
||||
*/
|
||||
async launch(options: LaunchCommand): Promise<void> {
|
||||
// Close existing browser if any
|
||||
if (this.state.browser) {
|
||||
if (this.browser) {
|
||||
await this.close();
|
||||
}
|
||||
|
||||
@@ -60,36 +66,138 @@ export class BrowserManager {
|
||||
: chromium;
|
||||
|
||||
// Launch browser
|
||||
this.state.browser = await launcher.launch({
|
||||
this.browser = await launcher.launch({
|
||||
headless: options.headless ?? true,
|
||||
});
|
||||
|
||||
// Create context with viewport
|
||||
this.state.context = await this.state.browser.newContext({
|
||||
const context = await this.browser.newContext({
|
||||
viewport: options.viewport ?? { width: 1280, height: 720 },
|
||||
});
|
||||
this.contexts.push(context);
|
||||
|
||||
// Create initial page
|
||||
this.state.page = await this.state.context.newPage();
|
||||
const page = await context.newPage();
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new tab in the current context
|
||||
*/
|
||||
async newTab(): Promise<{ index: number; total: number }> {
|
||||
if (!this.browser || this.contexts.length === 0) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
const context = this.contexts[0]; // Use first context for tabs
|
||||
const page = await context.newPage();
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
|
||||
return { index: this.activePageIndex, total: this.pages.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Create a new window (new context)
|
||||
*/
|
||||
async newWindow(viewport?: { width: number; height: number }): Promise<{ index: number; total: number }> {
|
||||
if (!this.browser) {
|
||||
throw new Error('Browser not launched');
|
||||
}
|
||||
|
||||
const context = await this.browser.newContext({
|
||||
viewport: viewport ?? { width: 1280, height: 720 },
|
||||
});
|
||||
this.contexts.push(context);
|
||||
|
||||
const page = await context.newPage();
|
||||
this.pages.push(page);
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
|
||||
return { index: this.activePageIndex, total: this.pages.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* Switch to a specific tab/page by index
|
||||
*/
|
||||
switchTo(index: number): { index: number; url: string; title: string } {
|
||||
if (index < 0 || index >= this.pages.length) {
|
||||
throw new Error(`Invalid tab index: ${index}. Available: 0-${this.pages.length - 1}`);
|
||||
}
|
||||
|
||||
this.activePageIndex = index;
|
||||
const page = this.pages[index];
|
||||
|
||||
return {
|
||||
index: this.activePageIndex,
|
||||
url: page.url(),
|
||||
title: '', // Title requires async, will be fetched separately
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Close a specific tab/page
|
||||
*/
|
||||
async closeTab(index?: number): Promise<{ closed: number; remaining: number }> {
|
||||
const targetIndex = index ?? this.activePageIndex;
|
||||
|
||||
if (targetIndex < 0 || targetIndex >= this.pages.length) {
|
||||
throw new Error(`Invalid tab index: ${targetIndex}`);
|
||||
}
|
||||
|
||||
if (this.pages.length === 1) {
|
||||
throw new Error('Cannot close the last tab. Use "close" to close the browser.');
|
||||
}
|
||||
|
||||
const page = this.pages[targetIndex];
|
||||
await page.close();
|
||||
this.pages.splice(targetIndex, 1);
|
||||
|
||||
// Adjust active index if needed
|
||||
if (this.activePageIndex >= this.pages.length) {
|
||||
this.activePageIndex = this.pages.length - 1;
|
||||
} else if (this.activePageIndex > targetIndex) {
|
||||
this.activePageIndex--;
|
||||
}
|
||||
|
||||
return { closed: targetIndex, remaining: this.pages.length };
|
||||
}
|
||||
|
||||
/**
|
||||
* List all tabs with their info
|
||||
*/
|
||||
async listTabs(): Promise<Array<{ index: number; url: string; title: string; active: boolean }>> {
|
||||
const tabs = await Promise.all(
|
||||
this.pages.map(async (page, index) => ({
|
||||
index,
|
||||
url: page.url(),
|
||||
title: await page.title().catch(() => ''),
|
||||
active: index === this.activePageIndex,
|
||||
}))
|
||||
);
|
||||
return tabs;
|
||||
}
|
||||
|
||||
/**
|
||||
* Close the browser and clean up
|
||||
*/
|
||||
async close(): Promise<void> {
|
||||
if (this.state.page) {
|
||||
await this.state.page.close().catch(() => {});
|
||||
this.state.page = null;
|
||||
for (const page of this.pages) {
|
||||
await page.close().catch(() => {});
|
||||
}
|
||||
this.pages = [];
|
||||
|
||||
for (const context of this.contexts) {
|
||||
await context.close().catch(() => {});
|
||||
}
|
||||
this.contexts = [];
|
||||
|
||||
if (this.browser) {
|
||||
await this.browser.close().catch(() => {});
|
||||
this.browser = 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;
|
||||
}
|
||||
this.activePageIndex = 0;
|
||||
}
|
||||
}
|
||||
|
||||
+7
-4
@@ -3,7 +3,7 @@ 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 { getSocketPath, isDaemonRunning, setSession, getSession } from './daemon.js';
|
||||
import type { Response } from './types.js';
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
@@ -14,6 +14,8 @@ export function setDebug(enabled: boolean): void {
|
||||
DEBUG = enabled;
|
||||
}
|
||||
|
||||
export { setSession, getSession };
|
||||
|
||||
function debug(...args: unknown[]): void {
|
||||
if (DEBUG) {
|
||||
console.error('[debug]', ...args);
|
||||
@@ -41,7 +43,8 @@ async function waitForSocket(maxAttempts = 30): Promise<boolean> {
|
||||
* Ensure daemon is running, start if not
|
||||
*/
|
||||
export async function ensureDaemon(): Promise<void> {
|
||||
debug('Checking if daemon is running...');
|
||||
const session = getSession();
|
||||
debug(`Checking if daemon is running for session "${session}"...`);
|
||||
if (isDaemonRunning()) {
|
||||
debug('Daemon already running');
|
||||
return;
|
||||
@@ -52,7 +55,7 @@ export async function ensureDaemon(): Promise<void> {
|
||||
const child = spawn(process.execPath, [daemonPath], {
|
||||
detached: true,
|
||||
stdio: 'ignore',
|
||||
env: { ...process.env, VEB_DAEMON: '1' },
|
||||
env: { ...process.env, VEB_DAEMON: '1', VEB_SESSION: session },
|
||||
});
|
||||
child.unref();
|
||||
|
||||
@@ -62,7 +65,7 @@ export async function ensureDaemon(): Promise<void> {
|
||||
throw new Error('Failed to start daemon');
|
||||
}
|
||||
|
||||
debug('Daemon started');
|
||||
debug(`Daemon started for session "${session}"`);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+42
-20
@@ -6,48 +6,67 @@ 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');
|
||||
// Session support - each session gets its own socket/pid
|
||||
let currentSession = process.env.VEB_SESSION || 'default';
|
||||
|
||||
/**
|
||||
* Get the socket path
|
||||
* Set the current session
|
||||
*/
|
||||
export function getSocketPath(): string {
|
||||
return SOCKET_PATH;
|
||||
export function setSession(session: string): void {
|
||||
currentSession = session;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID file path
|
||||
* Get the current session
|
||||
*/
|
||||
export function getPidFile(): string {
|
||||
return PID_FILE;
|
||||
export function getSession(): string {
|
||||
return currentSession;
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running
|
||||
* Get the socket path for the current session
|
||||
*/
|
||||
export function isDaemonRunning(): boolean {
|
||||
if (!fs.existsSync(PID_FILE)) return false;
|
||||
export function getSocketPath(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
return path.join(os.tmpdir(), `veb-${sess}.sock`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Get the PID file path for the current session
|
||||
*/
|
||||
export function getPidFile(session?: string): string {
|
||||
const sess = session ?? currentSession;
|
||||
return path.join(os.tmpdir(), `veb-${sess}.pid`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Check if daemon is running for the current session
|
||||
*/
|
||||
export function isDaemonRunning(session?: string): boolean {
|
||||
const pidFile = getPidFile(session);
|
||||
if (!fs.existsSync(pidFile)) return false;
|
||||
|
||||
try {
|
||||
const pid = parseInt(fs.readFileSync(PID_FILE, 'utf8').trim(), 10);
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
// Check if process exists
|
||||
process.kill(pid, 0);
|
||||
return true;
|
||||
} catch {
|
||||
// Process doesn't exist, clean up stale files
|
||||
cleanupSocket();
|
||||
cleanupSocket(session);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Clean up socket and PID file
|
||||
* Clean up socket and PID file for the current session
|
||||
*/
|
||||
export function cleanupSocket(): void {
|
||||
export function cleanupSocket(session?: string): void {
|
||||
const socketPath = getSocketPath(session);
|
||||
const pidFile = getPidFile(session);
|
||||
try {
|
||||
if (fs.existsSync(SOCKET_PATH)) fs.unlinkSync(SOCKET_PATH);
|
||||
if (fs.existsSync(PID_FILE)) fs.unlinkSync(PID_FILE);
|
||||
if (fs.existsSync(socketPath)) fs.unlinkSync(socketPath);
|
||||
if (fs.existsSync(pidFile)) fs.unlinkSync(pidFile);
|
||||
} catch {
|
||||
// Ignore cleanup errors
|
||||
}
|
||||
@@ -121,10 +140,13 @@ export async function startDaemon(): Promise<void> {
|
||||
});
|
||||
});
|
||||
|
||||
// Write PID file before listening
|
||||
fs.writeFileSync(PID_FILE, process.pid.toString());
|
||||
const socketPath = getSocketPath();
|
||||
const pidFile = getPidFile();
|
||||
|
||||
server.listen(SOCKET_PATH, () => {
|
||||
// Write PID file before listening
|
||||
fs.writeFileSync(pidFile, process.pid.toString());
|
||||
|
||||
server.listen(socketPath, () => {
|
||||
// Daemon is ready
|
||||
});
|
||||
|
||||
|
||||
+134
-2
@@ -1,7 +1,40 @@
|
||||
#!/usr/bin/env node
|
||||
import { send, setDebug } from './client.js';
|
||||
import * as fs from 'fs';
|
||||
import * as os from 'os';
|
||||
import * as path from 'path';
|
||||
import { send, setDebug, setSession, getSession } from './client.js';
|
||||
import type { Response } from './types.js';
|
||||
|
||||
/**
|
||||
* List all active veb sessions
|
||||
*/
|
||||
function listSessions(): string[] {
|
||||
const tmpDir = os.tmpdir();
|
||||
try {
|
||||
const files = fs.readdirSync(tmpDir);
|
||||
const sessions: string[] = [];
|
||||
|
||||
for (const file of files) {
|
||||
const match = file.match(/^veb-(.+)\.pid$/);
|
||||
if (match) {
|
||||
const pidFile = path.join(tmpDir, file);
|
||||
try {
|
||||
const pid = parseInt(fs.readFileSync(pidFile, 'utf8').trim(), 10);
|
||||
// Check if process is still running
|
||||
process.kill(pid, 0);
|
||||
sessions.push(match[1]);
|
||||
} catch {
|
||||
// Process not running, ignore
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return sessions;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
|
||||
// ANSI colors
|
||||
const colors = {
|
||||
reset: '\x1b[0m',
|
||||
@@ -39,7 +72,19 @@ ${c('yellow', 'Commands:')}
|
||||
${c('cyan', 'select')} <selector> <value> Select dropdown option
|
||||
${c('cyan', 'close')} Close browser and stop daemon
|
||||
|
||||
${c('yellow', 'Tab/Window Commands:')}
|
||||
${c('cyan', 'tab new')} Open a new tab
|
||||
${c('cyan', 'tab list')} List all open tabs
|
||||
${c('cyan', 'tab')} <index> Switch to tab by index
|
||||
${c('cyan', 'tab close')} [index] Close tab (current if no index)
|
||||
${c('cyan', 'window new')} Open a new window
|
||||
|
||||
${c('yellow', 'Session Commands:')}
|
||||
${c('cyan', 'session')} Show current session name
|
||||
${c('cyan', 'session list')} List all active sessions
|
||||
|
||||
${c('yellow', 'Options:')}
|
||||
--session <name> Use isolated browser session (or VEB_SESSION env)
|
||||
--json Output raw JSON (for agents)
|
||||
--selector, -s <sel> Target specific element
|
||||
--text, -t Wait for text instead of selector
|
||||
@@ -57,6 +102,9 @@ ${c('yellow', 'Examples:')}
|
||||
veb extract "table" --json
|
||||
veb eval "document.title"
|
||||
veb scroll down 500
|
||||
veb tab new
|
||||
veb tab list
|
||||
veb tab 0
|
||||
`);
|
||||
}
|
||||
|
||||
@@ -101,8 +149,24 @@ function printResponse(response: Response, jsonMode: boolean): void {
|
||||
console.log(c('green', '✓'), 'Done');
|
||||
} else if (data.launched) {
|
||||
console.log(c('green', '✓'), 'Browser launched');
|
||||
} else if (data.closed) {
|
||||
} else if (data.closed === true) {
|
||||
console.log(c('green', '✓'), 'Browser closed');
|
||||
} else if (data.tabs) {
|
||||
// Tab list
|
||||
const tabs = data.tabs as Array<{ index: number; url: string; title: string; active: boolean }>;
|
||||
tabs.forEach(tab => {
|
||||
const marker = tab.active ? c('green', '→') : ' ';
|
||||
const idx = c('cyan', `[${tab.index}]`);
|
||||
const title = tab.title || c('dim', '(untitled)');
|
||||
console.log(`${marker} ${idx} ${title}`);
|
||||
if (tab.url) console.log(c('dim', ` ${tab.url}`));
|
||||
});
|
||||
} else if (data.index !== undefined && data.total !== undefined) {
|
||||
// Tab new / window new
|
||||
console.log(c('green', '✓'), `Tab ${data.index} created (${data.total} total)`);
|
||||
} else if (data.remaining !== undefined) {
|
||||
// Tab close
|
||||
console.log(c('green', '✓'), `Tab closed (${data.remaining} remaining)`);
|
||||
} else {
|
||||
console.log(c('green', '✓'), JSON.stringify(data));
|
||||
}
|
||||
@@ -117,6 +181,13 @@ async function main(): Promise<void> {
|
||||
setDebug(true);
|
||||
}
|
||||
|
||||
// Handle session - check --session flag first, then env var
|
||||
const sessionIdx = args.findIndex(a => a === '--session');
|
||||
if (sessionIdx !== -1 && args[sessionIdx + 1]) {
|
||||
setSession(args[sessionIdx + 1]);
|
||||
}
|
||||
// VEB_SESSION env var is already handled by daemon.ts default
|
||||
|
||||
if (args.length === 0 || args.includes('--help') || args.includes('-h')) {
|
||||
printHelp();
|
||||
process.exit(0);
|
||||
@@ -132,6 +203,7 @@ async function main(): Promise<void> {
|
||||
// Check if previous arg was a flag that takes a value
|
||||
const prev = args[i - 1];
|
||||
if (prev === '--selector' || prev === '-s') return false;
|
||||
if (prev === '--session') return false;
|
||||
return true;
|
||||
});
|
||||
const command = cleanArgs[0];
|
||||
@@ -292,6 +364,66 @@ async function main(): Promise<void> {
|
||||
break;
|
||||
}
|
||||
|
||||
case 'tab': {
|
||||
const subCmd = cleanArgs[1];
|
||||
|
||||
if (subCmd === 'new') {
|
||||
cmd = { id, action: 'tab_new' };
|
||||
} else if (subCmd === 'list' || subCmd === 'ls') {
|
||||
cmd = { id, action: 'tab_list' };
|
||||
} else if (subCmd === 'close') {
|
||||
const tabIndex = cleanArgs[2] !== undefined ? parseInt(cleanArgs[2], 10) : undefined;
|
||||
cmd = { id, action: 'tab_close', index: tabIndex };
|
||||
} else if (subCmd !== undefined) {
|
||||
// Assume it's a tab index to switch to
|
||||
const tabIndex = parseInt(subCmd, 10);
|
||||
if (isNaN(tabIndex)) {
|
||||
console.error(c('red', 'Error:'), `Invalid tab command or index: ${subCmd}`);
|
||||
process.exit(1);
|
||||
}
|
||||
cmd = { id, action: 'tab_switch', index: tabIndex };
|
||||
} else {
|
||||
// No subcommand - list tabs
|
||||
cmd = { id, action: 'tab_list' };
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'window': {
|
||||
const subCmd = cleanArgs[1];
|
||||
|
||||
if (subCmd === 'new') {
|
||||
cmd = { id, action: 'window_new' };
|
||||
} else {
|
||||
console.error(c('red', 'Error:'), 'Usage: veb window new');
|
||||
process.exit(1);
|
||||
}
|
||||
break;
|
||||
}
|
||||
|
||||
case 'session': {
|
||||
const subCmd = cleanArgs[1];
|
||||
|
||||
if (subCmd === 'list' || subCmd === 'ls') {
|
||||
const sessions = listSessions();
|
||||
const currentSession = getSession();
|
||||
|
||||
if (sessions.length === 0) {
|
||||
console.log(c('dim', 'No active sessions'));
|
||||
} else {
|
||||
sessions.forEach(sess => {
|
||||
const marker = sess === currentSession ? c('green', '→') : ' ';
|
||||
console.log(`${marker} ${c('cyan', sess)}`);
|
||||
});
|
||||
}
|
||||
process.exit(0);
|
||||
} else {
|
||||
// Show current session
|
||||
console.log(c('cyan', getSession()));
|
||||
process.exit(0);
|
||||
}
|
||||
}
|
||||
|
||||
default:
|
||||
console.error(c('red', 'Error:'), `Unknown command: ${command}`);
|
||||
console.error(c('dim', 'Run veb --help for usage'));
|
||||
|
||||
@@ -101,6 +101,33 @@ const closeSchema = baseCommandSchema.extend({
|
||||
action: z.literal('close'),
|
||||
});
|
||||
|
||||
// Tab/Window schemas
|
||||
const tabNewSchema = baseCommandSchema.extend({
|
||||
action: z.literal('tab_new'),
|
||||
});
|
||||
|
||||
const tabListSchema = baseCommandSchema.extend({
|
||||
action: z.literal('tab_list'),
|
||||
});
|
||||
|
||||
const tabSwitchSchema = baseCommandSchema.extend({
|
||||
action: z.literal('tab_switch'),
|
||||
index: z.number().nonnegative(),
|
||||
});
|
||||
|
||||
const tabCloseSchema = baseCommandSchema.extend({
|
||||
action: z.literal('tab_close'),
|
||||
index: z.number().nonnegative().optional(),
|
||||
});
|
||||
|
||||
const windowNewSchema = baseCommandSchema.extend({
|
||||
action: z.literal('window_new'),
|
||||
viewport: z.object({
|
||||
width: z.number().positive(),
|
||||
height: z.number().positive(),
|
||||
}).optional(),
|
||||
});
|
||||
|
||||
// Union schema for all commands
|
||||
const commandSchema = z.discriminatedUnion('action', [
|
||||
launchSchema,
|
||||
@@ -117,6 +144,11 @@ const commandSchema = z.discriminatedUnion('action', [
|
||||
hoverSchema,
|
||||
contentSchema,
|
||||
closeSchema,
|
||||
tabNewSchema,
|
||||
tabListSchema,
|
||||
tabSwitchSchema,
|
||||
tabCloseSchema,
|
||||
windowNewSchema,
|
||||
]);
|
||||
|
||||
// Parse result type
|
||||
|
||||
+58
-1
@@ -97,6 +97,30 @@ export interface CloseCommand extends BaseCommand {
|
||||
action: 'close';
|
||||
}
|
||||
|
||||
// Tab/Window commands
|
||||
export interface TabNewCommand extends BaseCommand {
|
||||
action: 'tab_new';
|
||||
}
|
||||
|
||||
export interface TabListCommand extends BaseCommand {
|
||||
action: 'tab_list';
|
||||
}
|
||||
|
||||
export interface TabSwitchCommand extends BaseCommand {
|
||||
action: 'tab_switch';
|
||||
index: number;
|
||||
}
|
||||
|
||||
export interface TabCloseCommand extends BaseCommand {
|
||||
action: 'tab_close';
|
||||
index?: number;
|
||||
}
|
||||
|
||||
export interface WindowNewCommand extends BaseCommand {
|
||||
action: 'window_new';
|
||||
viewport?: { width: number; height: number };
|
||||
}
|
||||
|
||||
// Union of all command types
|
||||
export type Command =
|
||||
| LaunchCommand
|
||||
@@ -112,7 +136,12 @@ export type Command =
|
||||
| SelectCommand
|
||||
| HoverCommand
|
||||
| ContentCommand
|
||||
| CloseCommand;
|
||||
| CloseCommand
|
||||
| TabNewCommand
|
||||
| TabListCommand
|
||||
| TabSwitchCommand
|
||||
| TabCloseCommand
|
||||
| WindowNewCommand;
|
||||
|
||||
// Response types
|
||||
export interface SuccessResponse<T = unknown> {
|
||||
@@ -152,6 +181,34 @@ export interface ContentData {
|
||||
html: string;
|
||||
}
|
||||
|
||||
export interface TabInfo {
|
||||
index: number;
|
||||
url: string;
|
||||
title: string;
|
||||
active: boolean;
|
||||
}
|
||||
|
||||
export interface TabListData {
|
||||
tabs: TabInfo[];
|
||||
active: number;
|
||||
}
|
||||
|
||||
export interface TabNewData {
|
||||
index: number;
|
||||
total: number;
|
||||
}
|
||||
|
||||
export interface TabSwitchData {
|
||||
index: number;
|
||||
url: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export interface TabCloseData {
|
||||
closed: number;
|
||||
remaining: number;
|
||||
}
|
||||
|
||||
// Browser state
|
||||
export interface BrowserState {
|
||||
browser: Browser | null;
|
||||
|
||||
Reference in New Issue
Block a user