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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions packages/cli/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,11 @@ ${pc.cyan("TRACING")}
bap trace --export=<file> Export trace as JSON
bap trace --limit=<N> Show last N entries (default: 10)

${pc.cyan("DEBUGGING")}
bap watch Stream live browser events
bap watch --filter=console Filter by event type
bap demo Guided walkthrough for first-time users

${pc.cyan("CONFIGURATION")}
bap config [key] [value] View/set configuration
bap install-skill Install skill to all detected agents
Expand Down Expand Up @@ -147,6 +152,7 @@ const CLIENT_ONLY_COMMANDS = new Set([
"close-all", // tears down everything — don't auto-create
"sessions", // informational — just lists contexts
"tabs", // informational — just lists pages
"watch", // long-running event stream — don't auto-create browser
]);

// =============================================================================
Expand Down Expand Up @@ -201,6 +207,7 @@ ${pc.cyan("Quick start:")}
${pc.green("bap act")} fill:e5="hi" click:e12 Multi-step actions
${pc.green("bap trace")} View session history

${pc.dim("Run")} bap demo ${pc.dim("for a guided walkthrough")}
${pc.dim("Run")} bap --help ${pc.dim("for all commands")}
`);
process.exit(0);
Expand Down
89 changes: 89 additions & 0 deletions packages/cli/src/commands/demo.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,89 @@
/**
* bap demo — Guided walkthrough of BAP capabilities
*
* Navigates to example.com, observes elements, clicks a link,
* and explains each step for first-time users.
*/

import { pc } from "@browseragentprotocol/logger";
import type { BAPClient } from "@browseragentprotocol/client";
import type { GlobalFlags } from "../config/state.js";
import { register } from "./registry.js";

function step(n: number, text: string): void {
console.log(`\n${pc.cyan(`Step ${n}:`)} ${pc.bold(text)}`);
}

function explain(text: string): void {
console.log(pc.dim(` ${text}`));
}

async function demoCommand(_args: string[], _flags: GlobalFlags, client: BAPClient): Promise<void> {
console.log(`\n${pc.bold("BAP Demo")} ${pc.dim("— see BAP in action")}\n`);

// Step 1: Navigate
step(1, "Navigate to a page");
explain("bap goto https://example.com");
const nav = await client.navigate("https://example.com");
console.log(` ${pc.green("Loaded:")} ${nav.url} (${nav.status})`);

// Step 2: Observe
step(2, "Observe interactive elements");
explain("bap observe");
const obs = await client.observe({ maxElements: 10 });
const elements = obs.interactiveElements ?? [];
if (elements.length > 0) {
console.log(` ${pc.green(`Found ${elements.length} element(s):`)}`);
for (const el of elements.slice(0, 5)) {
const ref = el.ref ?? "";
const role = el.role ?? el.tagName ?? "";
const name = el.name ?? "";
console.log(` ${pc.yellow(ref)} ${role}${name ? ` "${name}"` : ""}`);
}
if (elements.length > 5) {
console.log(pc.dim(` ... and ${elements.length - 5} more`));
}
} else {
console.log(` ${pc.dim("No interactive elements found on this page")}`);
}

// Step 3: Click a link
const link = elements.find((e: { role: string; name?: string }) => e.role === "link" && e.name);
if (link) {
step(3, `Click a link using its ref`);
explain(`bap click ${link.ref}`);
await client.click({ type: "ref", ref: link.ref! });
const afterClick = await client.observe({
includeMetadata: true,
includeInteractiveElements: false,
maxElements: 0,
});
console.log(` ${pc.green("Navigated to:")} ${afterClick.metadata?.url ?? "unknown"}`);
} else {
step(3, "Click an element");
console.log(` ${pc.dim("No clickable links found — skipping")}`);
}

// Step 4: Take a screenshot
step(link ? 4 : 3, "Take a screenshot");
explain("bap screenshot");
await client.screenshot();
console.log(` ${pc.green("Screenshot saved to .bap/ directory")}`);

// Summary
console.log(`
${pc.bold("What you just saw:")}
${pc.cyan("goto")} Navigate to any URL
${pc.cyan("observe")} See interactive elements with refs (${pc.yellow("e1")}, ${pc.yellow("e2")}, ...)
${pc.cyan("click")} Click elements using refs or semantic selectors
${pc.cyan("screenshot")} Capture the page

${pc.bold("Try next:")}
${pc.green("bap goto")} <your-url> ${pc.dim("--observe")}
${pc.green("bap act")} fill:e5="hello" click:e12
${pc.green("bap extract")} --fields="title,content"
${pc.green("bap --help")} ${pc.dim("for all commands")}
`);
}

register("demo", demoCommand);
2 changes: 2 additions & 0 deletions packages/cli/src/commands/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,3 +32,5 @@ import "./config.js";
import "./recipe.js";
import "./install-skill.js";
import "./trace.js";
import "./demo.js";
import "./watch.js";
172 changes: 172 additions & 0 deletions packages/cli/src/commands/watch.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
/**
* bap watch — Stream live browser events to terminal
*
* Long-running command that subscribes to page, console, network,
* dialog, and download events. Runs until Ctrl+C.
*
* Usage:
* bap watch All events
* bap watch --filter=console,network Only console and network
* bap watch --format=json JSON output for piping
*/

import { pc } from "@browseragentprotocol/logger";
import type { BAPClient } from "@browseragentprotocol/client";
import type {
PageEvent,
ConsoleEvent,
NetworkEvent,
DialogEvent,
DownloadEvent,
} from "@browseragentprotocol/protocol";
import type { GlobalFlags } from "../config/state.js";
import { getOutputFormat } from "../output/formatter.js";
import { register } from "./registry.js";

function formatTime(ts: number): string {
return new Date(ts).toLocaleTimeString("en-US", { hour12: false });
}

function levelColor(level: string): (s: string) => string {
switch (level) {
case "error":
return pc.red;
case "warn":
return pc.yellow;
case "debug":
return pc.dim;
default:
return (s: string) => s;
}
}

function statusColor(status: number): (s: string) => string {
if (status >= 400) return pc.red;
if (status >= 300) return pc.yellow;
return pc.green;
}

function formatPageEvent(event: PageEvent): string {
const time = formatTime(event.timestamp);
return `${pc.dim(time)} ${pc.cyan("PAGE")} ${event.type}${event.url ? ` ${event.url}` : ""}`;
}

function formatConsoleEvent(event: ConsoleEvent): string {
const time = formatTime(event.timestamp);
const colorFn = levelColor(event.level);
const label = colorFn(event.level.toUpperCase().padEnd(5));
const text = event.text.length > 200 ? event.text.slice(0, 200) + "..." : event.text;
return `${pc.dim(time)} ${pc.magenta("CONSOLE")} ${label} ${text}`;
}

function formatNetworkEvent(event: NetworkEvent): string {
const time = formatTime(event.timestamp);
if (event.type === "request") {
const method = event.method.padEnd(4);
return `${pc.dim(time)} ${pc.blue("NET")} ${pc.dim("→")} ${method} ${event.url}`;
} else if (event.type === "response") {
const colorFn = statusColor(event.status);
return `${pc.dim(time)} ${pc.blue("NET")} ${pc.dim("←")} ${colorFn(String(event.status))} ${event.url}`;
} else {
return `${pc.dim(time)} ${pc.red("NET")} ${pc.red("✗")} ${event.url} ${pc.dim(event.error)}`;
}
}

function formatDialogEvent(event: DialogEvent): string {
const time = formatTime(event.timestamp);
return `${pc.dim(time)} ${pc.yellow("DIALOG")} ${event.type}: ${event.message}`;
}

function formatDownloadEvent(event: DownloadEvent): string {
const time = formatTime(event.timestamp);
return `${pc.dim(time)} ${pc.green("DOWNLOAD")} ${event.state} ${event.suggestedFilename ?? event.url}`;
}

async function watchCommand(args: string[], _flags: GlobalFlags, client: BAPClient): Promise<void> {
const format = getOutputFormat();
const isJson = format === "json";

// Parse --filter flag from args
const filterArg = args.find((a) => a.startsWith("--filter="));
const allowedTypes = filterArg ? new Set(filterArg.slice("--filter=".length).split(",")) : null;

const shouldShow = (type: string): boolean => !allowedTypes || allowedTypes.has(type);

if (!isJson) {
const filterLabel = allowedTypes ? ` (${[...allowedTypes].join(", ")})` : "";
console.log(`${pc.bold("Watching browser events")}${pc.dim(filterLabel)}`);
console.log(pc.dim("Press Ctrl+C to stop\n"));
}

// Subscribe to events
if (shouldShow("page")) {
client.on("page", (event: PageEvent) => {
if (isJson) {
console.log(JSON.stringify({ eventType: "page", ...event }));
} else {
console.log(formatPageEvent(event));
}
});
}

if (shouldShow("console")) {
client.on("console", (event: ConsoleEvent) => {
if (isJson) {
console.log(JSON.stringify({ eventType: "console", ...event }));
} else {
console.log(formatConsoleEvent(event));
}
});
}

if (shouldShow("network")) {
client.on("network", (event: NetworkEvent) => {
if (isJson) {
console.log(JSON.stringify({ eventType: "network", ...event }));
} else {
console.log(formatNetworkEvent(event));
}
});
}

if (shouldShow("dialog")) {
client.on("dialog", (event: DialogEvent) => {
if (isJson) {
console.log(JSON.stringify({ eventType: "dialog", ...event }));
} else {
console.log(formatDialogEvent(event));
}
});
}

if (shouldShow("download")) {
client.on("download", (event: DownloadEvent) => {
if (isJson) {
console.log(JSON.stringify({ eventType: "download", ...event }));
} else {
console.log(formatDownloadEvent(event));
}
});
}

// Keep the process alive until interrupted
await new Promise<void>((resolve) => {
let resolved = false;
const cleanup = () => {
if (resolved) return;
resolved = true;
process.off("SIGINT", cleanup);
process.off("SIGTERM", cleanup);
client.off("close", cleanup);
if (!isJson) {
console.log(pc.dim("\nStopped watching."));
}
resolve();
};
process.once("SIGINT", cleanup);
process.once("SIGTERM", cleanup);
client.on("close", cleanup);
});
}

register("watch", watchCommand);
8 changes: 8 additions & 0 deletions packages/mcp/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,14 @@
"@modelcontextprotocol/sdk": "^1.27.1",
"zod": "^3.23.0"
},
"peerDependencies": {
"@browseragentprotocol/server-playwright": "workspace:*"
},
"peerDependenciesMeta": {
"@browseragentprotocol/server-playwright": {
"optional": true
}
},
"devDependencies": {
"tsup": "^8.3.0",
"typescript": "^5.7.0"
Expand Down
19 changes: 15 additions & 4 deletions packages/mcp/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ interface CLIArgs {
headless?: boolean;
allowedDomains?: string[];
slim?: boolean;
inProcess?: boolean;
help?: boolean;
version?: boolean;
}
Expand Down Expand Up @@ -70,6 +71,8 @@ function parseArgs(): CLIArgs {
args.allowedDomains = argv[++i]?.split(",").map((d) => d.trim());
} else if (arg === "--slim") {
args.slim = true;
} else if (arg === "--in-process") {
args.inProcess = true;
}
}

Expand All @@ -94,6 +97,8 @@ ${pc.cyan("OPTIONS")}
${pc.yellow("--no-headless")} Run with visible browser window
${pc.yellow("-v, --verbose")} Enable verbose logging to stderr
${pc.yellow("--allowed-domains")} ${pc.dim("<list>")} Comma-separated list of allowed domains
${pc.yellow("--in-process")} Run Playwright server in same process ${pc.dim("(no WebSocket)")}
${pc.yellow("--slim")} Expose only 5 essential tools
${pc.yellow("-h, --help")} Show this help message
${pc.yellow("--version")} Show version

Expand Down Expand Up @@ -324,23 +329,28 @@ async function main(): Promise<void> {
}

if (args.version) {
console.error(`${icons.connection} BAP MCP Server ${pc.dim("v0.6.0")}`);
console.error(`${icons.connection} BAP MCP Server ${pc.dim("v0.8.0")}`);
process.exit(0);
}

if (args.verbose) {
log.setLevel("debug");
}

// Determine mode: standalone (auto-start server) vs connect to existing
const isStandalone = !args.url;
// Determine mode: in-process vs standalone (auto-start server) vs connect to existing
const isInProcess = args.inProcess ?? false;
const isStandalone = !args.url && !isInProcess;
const port = args.port ?? 9222;
const host = "localhost";
const bapServerUrl = args.url ?? `ws://${host}:${port}`;
let serverProcess: ChildProcess | null = null;

try {
if (isStandalone) {
if (isInProcess) {
if (args.verbose) {
log.info("In-process mode: running Playwright server in same process");
}
} else if (isStandalone) {
if (args.verbose) {
log.info("Standalone mode: auto-starting BAP Playwright server");
}
Expand All @@ -361,6 +371,7 @@ async function main(): Promise<void> {
verbose: args.verbose,
allowedDomains: args.allowedDomains,
slim: args.slim,
inProcess: isInProcess,
});

// Graceful shutdown — clean up MCP server and child process
Expand Down
Loading
Loading