Reorganize: Move all skills to skills/ folder

- Created skills/ directory
- Moved 272 skills to skills/ subfolder
- Kept agents/ at root level
- Kept installation scripts and docs at root level

Repository structure:
- skills/           - All 272 skills from skills.sh
- agents/           - Agent definitions
- *.sh, *.ps1       - Installation scripts
- README.md, etc.   - Documentation

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
admin
2026-01-23 18:05:17 +00:00
Unverified
parent 2b4e974878
commit b723e2bd7d
4083 changed files with 1056 additions and 1098063 deletions

View File

@@ -0,0 +1,61 @@
# Browser Automation Skill
A skill for seamlessly enabling **[Claude Code](https://docs.claude.com/en/docs/claude-code/overview)** to interface with a browser using **[Stagehand](https://github.com/browserbase/stagehand)** (AI browser automation framework). Because Stagehand accepts natural language instructions, it's significantly more context-efficient than native Playwright while providing more features built for automation.
## Installation
On Claude Code, to add the marketplace, simply run:
```bash
/plugin marketplace add browserbase/agent-browse
```
Then install the plugin:
```bash
/plugin install agent-browse@browserbase
```
If you prefer the manual interface:
1. On Claude Code, type `/plugin`
2. Select option `3. Add marketplace`
3. Enter the marketplace source: `browserbase/agent-browse`
4. Press enter to select the `agent-browse` plugin
5. Hit enter again to `Install now`
6. **Restart Claude Code** for changes to take effect
## Setup
Set your Anthropic API key:
```bash
export ANTHROPIC_API_KEY="your-api-key"
```
## Usage
Once installed, just ask Claude to browse:
- *"Go to Hacker News, get the top post comments, and summarize them "*
- *"QA test http://localhost:3000 and fix any bugs you encounter"*
- *"Order me a pizza, you're already signed in on Doordash"*
Claude will handle the rest.
## Troubleshooting
### Chrome not found
Install Chrome for your platform:
- **macOS** or **Windows**: https://www.google.com/chrome/
- **Linux**: `sudo apt install google-chrome-stable`
### Profile refresh
To refresh cookies from your main Chrome profile:
```bash
rm -rf .chrome-profile
```
## Resources
- [Stagehand Documentation](https://github.com/browserbase/stagehand)
- [Claude Code Skills](https://support.claude.com/en/articles/12512176-what-are-skills)

View File

@@ -0,0 +1,241 @@
import { query } from '@anthropic-ai/claude-agent-sdk';
import * as readline from "readline";
import { prepareChromeProfile } from './src/browser-utils.js';
import { fileURLToPath } from 'url';
import { dirname } from 'path';
// Resolve plugin root directory
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PLUGIN_ROOT = __dirname; // agent-browse.ts is in the root
// ANSI color codes for prettier output
const colors = {
reset: '\x1b[0m',
bright: '\x1b[1m',
dim: '\x1b[2m',
cyan: '\x1b[36m',
green: '\x1b[32m',
yellow: '\x1b[33m',
red: '\x1b[31m',
magenta: '\x1b[35m',
blue: '\x1b[34m',
};
async function main() {
// Prepare Chrome profile before starting the agent (first run only)
prepareChromeProfile(PLUGIN_ROOT);
// Get initial prompt from command line arguments
const args = process.argv.slice(2);
const hasInitialPrompt = args.length > 0;
const initialPrompt = hasInitialPrompt ? args.join(' ') : null;
if (hasInitialPrompt) {
console.log(`${colors.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}`);
console.log(`${colors.bright}${colors.cyan}You:${colors.reset} ${initialPrompt}`);
console.log(`${colors.dim}━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━${colors.reset}\n`);
}
// Create readline interface for interactive input
const rl = readline.createInterface({
input: process.stdin,
output: process.stdout
});
const getUserInput = (prompt: string = `\n${colors.bright}${colors.cyan}You:${colors.reset} `): Promise<string> => {
return new Promise((resolve) => {
rl.question(prompt, (answer) => {
resolve(answer);
});
});
};
let shouldPromptUser = !hasInitialPrompt; // If no initial prompt, ask for input immediately
let conversationActive = true;
// Streaming input mode: creates an async generator for multi-turn conversations
async function* generateMessages() {
// Send initial prompt if provided
if (initialPrompt) {
yield {
type: "user" as const,
message: {
role: "user" as const,
content: initialPrompt
},
parent_tool_use_id: null,
session_id: "default"
};
}
// Keep accepting new messages
while (conversationActive) {
// Wait until we're ready for next input
while (!shouldPromptUser && conversationActive) {
await new Promise(resolve => setTimeout(resolve, 100));
}
if (!conversationActive) break;
shouldPromptUser = false;
const userInput = await getUserInput();
if (userInput.toLowerCase() === 'exit' || userInput.toLowerCase() === 'quit') {
conversationActive = false;
console.log(`\n${colors.dim}Goodbye!${colors.reset}`);
break;
}
yield {
type: "user" as const,
message: {
role: "user" as const,
content: userInput
},
parent_tool_use_id: null,
session_id: "default"
};
}
}
const q = query({
prompt: generateMessages(),
options: {
systemPrompt: {
type: 'preset',
preset: 'claude_code',
append: `\n\n# Browser Automation via CLI
For browser automation tasks, use bash commands to call the CLI tool:
**Available commands:**
- \`tsx src/cli.ts navigate <url>\` - Navigate to a URL and take screenshot
- \`tsx src/cli.ts act "<action>"\` - Perform natural language action and take screenshot
- \`tsx src/cli.ts extract "<instruction>" '{"field": "type"}'\` - Extract structured data
- \`tsx src/cli.ts observe "<query>"\` - Discover elements on page
- \`tsx src/cli.ts screenshot\` - Take a screenshot
- \`tsx src/cli.ts close\` - Close the browser
**Important:**
- Always navigate first before performing actions
- Be as specific as possible in your action descriptions
- Check the success field in JSON output
- The browser stays open between commands for faster operations
- Always close the browser when done with tasks
- Use the TodoWrite tool to track your browser automation steps
All commands output JSON with success status and relevant data.`
},
maxTurns: 100,
cwd: process.cwd(),
model: "sonnet",
executable: "node",
},
});
for await (const message of q) {
// Handle assistant messages (Claude's responses and tool uses)
if (message.type === 'assistant' && message.message) {
const textContent = message.message.content.find((c: any) => c.type === 'text');
if (textContent && 'text' in textContent) {
console.log(`\n${colors.bright}${colors.magenta}Claude:${colors.reset} ${textContent.text}`);
}
// Show tool uses (but not tool results - those come in 'user' type messages)
const toolUses = message.message.content.filter((c: any) => c.type === 'tool_use');
for (const toolUse of toolUses) {
const toolName = (toolUse as any).name;
console.log(`\n${colors.blue}🔧 Using tool: ${colors.reset}${colors.bright}${toolName}${colors.reset}`);
const input = JSON.stringify((toolUse as any).input, null, 2);
const indentedInput = input.split('\n').map(line => ` ${colors.dim}${line}${colors.reset}`).join('\n');
console.log(indentedInput);
}
}
// Handle tool results (these come as 'user' type messages)
if (message.type === 'user' && message.message) {
const content = message.message.content;
// Content can be a string or an array
if (Array.isArray(content)) {
const toolResults = content.filter((c: any) => c.type === 'tool_result');
for (const result of toolResults as any[]) {
// Handle errors
if (result.is_error) {
const errorText = typeof result.content === 'string'
? result.content
: JSON.stringify(result.content);
console.log(`\n${colors.red}❌ Tool error:${colors.reset} ${errorText}`);
continue;
}
// Handle successful results
if (result.content) {
// Content can be a string or an array
if (typeof result.content === 'string') {
console.log(`\n${colors.green}✓ Tool result: ${colors.reset}${colors.dim}${result.content}${colors.reset}`);
} else if (Array.isArray(result.content)) {
const textResult = result.content.find((c: any) => c.type === 'text');
if (textResult) {
console.log(`\n${colors.green}✓ Tool result: ${colors.reset}${colors.dim}${textResult.text}${colors.reset}`);
}
}
}
}
}
}
// Handle result message - this signals the conversation is complete and we should prompt for input
if (message.type === 'result') {
// Hand control back to user for follow-up questions
shouldPromptUser = true;
}
}
// Only close readline when conversation is fully done
rl.close();
// Close browser before exiting
await closeBrowserOnExit();
process.exit(0);
}
async function closeBrowserOnExit() {
try {
console.log(`\n${colors.dim}Closing browser...${colors.reset}`);
const { spawn } = await import('child_process');
const closeProcess = spawn('tsx', ['src/cli.ts', 'close'], {
stdio: 'inherit'
});
// Wait for close command to complete (max 10 seconds)
await new Promise<void>((resolve) => {
const timeout = setTimeout(() => {
closeProcess.kill();
resolve();
}, 10000);
closeProcess.on('close', () => {
clearTimeout(timeout);
resolve();
});
});
} catch (error) {
// Ignore errors during cleanup
}
}
// Handle Ctrl+C and other termination signals
process.on('SIGINT', async () => {
console.log(`\n\n${colors.dim}Interrupted. Closing browser...${colors.reset}`);
await closeBrowserOnExit();
process.exit(0);
});
process.on('SIGTERM', async () => {
console.log(`\n${colors.dim}Terminating. Closing browser...${colors.reset}`);
await closeBrowserOnExit();
process.exit(0);
});
main().catch(console.error);

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,26 @@
{
"name": "agent-browse",
"version": "0.0.1",
"type": "module",
"bin": {
"browser": "./dist/src/cli.js"
},
"scripts": {
"claude": "tsx agent-browse.ts",
"build": "tsc",
"postinstall": "npm run build"
},
"dependencies": {
"@anthropic-ai/claude-agent-sdk": "^0.1.76",
"@browserbasehq/stagehand": "^3.0.7",
"dotenv": "^16.4.5",
"sharp": "^0.34.4",
"zod": "^4.2.1"
},
"devDependencies": {
"@types/node": "^24.7.2",
"tsx": "^4.20.6",
"typescript": "^5.9.3"
},
"packageManager": "pnpm@10.12.1+sha512.f0dda8580f0ee9481c5c79a1d927b9164f2c478e90992ad268bbb2465a736984391d6333d2c327913578b2804af33474ca554ba29c04a8b13060a717675ae3ac"
}

3814
skills/plugins/agent-browse/pnpm-lock.yaml generated Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,217 @@
import { Stagehand } from '@browserbasehq/stagehand';
import { existsSync, cpSync, mkdirSync, readFileSync } from 'fs';
import { platform } from 'os';
import { join } from 'path';
import { execSync } from 'child_process';
// Retrieve Claude Code API key from system keychain
export function getClaudeCodeApiKey(): string | null {
try {
if (platform() === 'darwin') {
const result = execSync(
'security find-generic-password -s "Claude Code" -w 2>/dev/null',
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
if (result && result.startsWith('sk-ant-')) {
return result;
}
} else if (platform() === 'win32') {
try {
const psCommand = `$cred = Get-StoredCredential -Target "Claude Code" -ErrorAction SilentlyContinue; if ($cred) { $cred.GetNetworkCredential().Password }`;
const result = execSync(`powershell -Command "${psCommand}"`, {
encoding: 'utf-8',
stdio: ['pipe', 'pipe', 'pipe']
}).trim();
if (result && result.startsWith('sk-ant-')) {
return result;
}
} catch {}
} else {
// Linux
const configPaths = [
join(process.env.HOME || '', '.claude', 'credentials'),
join(process.env.HOME || '', '.config', 'claude-code', 'credentials'),
join(process.env.XDG_CONFIG_HOME || join(process.env.HOME || '', '.config'), 'claude-code', 'credentials'),
];
for (const configPath of configPaths) {
if (existsSync(configPath)) {
try {
const content = readFileSync(configPath, 'utf-8').trim();
if (content.startsWith('sk-ant-')) {
return content;
}
const parsed = JSON.parse(content);
if (parsed.apiKey && parsed.apiKey.startsWith('sk-ant-')) {
return parsed.apiKey;
}
} catch {}
}
}
try {
const result = execSync(
'secret-tool lookup service "Claude Code" 2>/dev/null',
{ encoding: 'utf-8', stdio: ['pipe', 'pipe', 'pipe'] }
).trim();
if (result && result.startsWith('sk-ant-')) {
return result;
}
} catch {}
}
} catch {}
return null;
}
// Get API key from env or Claude Code keychain
export function getAnthropicApiKey(): { apiKey: string; source: 'env' | 'claude-code' } | null {
if (process.env.ANTHROPIC_API_KEY) {
return { apiKey: process.env.ANTHROPIC_API_KEY, source: 'env' };
}
const claudeCodeKey = getClaudeCodeApiKey();
if (claudeCodeKey) {
return { apiKey: claudeCodeKey, source: 'claude-code' };
}
return null;
}
/**
* Finds the local Chrome installation path based on the operating system
* @returns The path to the Chrome executable, or undefined if not found
*/
export function findLocalChrome(): string | undefined {
const systemPlatform = platform();
const chromePaths: string[] = [];
if (systemPlatform === 'darwin') {
// macOS paths
chromePaths.push(
'/Applications/Google Chrome.app/Contents/MacOS/Google Chrome',
'/Applications/Chromium.app/Contents/MacOS/Chromium',
`${process.env.HOME}/Applications/Google Chrome.app/Contents/MacOS/Google Chrome`,
`${process.env.HOME}/Applications/Chromium.app/Contents/MacOS/Chromium`
);
} else if (systemPlatform === 'win32') {
// Windows paths
chromePaths.push(
'C:\\Program Files\\Google\\Chrome\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Google\\Chrome\\Application\\chrome.exe',
`${process.env.LOCALAPPDATA}\\Google\\Chrome\\Application\\chrome.exe`,
`${process.env.PROGRAMFILES}\\Google\\Chrome\\Application\\chrome.exe`,
`${process.env['PROGRAMFILES(X86)']}\\Google\\Chrome\\Application\\chrome.exe`,
'C:\\Program Files\\Chromium\\Application\\chrome.exe',
'C:\\Program Files (x86)\\Chromium\\Application\\chrome.exe'
);
} else {
// Linux paths
chromePaths.push(
'/usr/bin/google-chrome',
'/usr/bin/google-chrome-stable',
'/usr/bin/chromium',
'/usr/bin/chromium-browser',
'/snap/bin/chromium',
'/usr/local/bin/google-chrome',
'/usr/local/bin/chromium',
'/opt/google/chrome/chrome',
'/opt/google/chrome/google-chrome'
);
}
// Find the first existing Chrome installation
for (const path of chromePaths) {
if (path && existsSync(path)) {
return path;
}
}
return undefined;
}
/**
* Gets the Chrome user data directory path based on the operating system
* @returns The path to Chrome's user data directory, or undefined if not found
*/
export function getChromeUserDataDir(): string | undefined {
const systemPlatform = platform();
if (systemPlatform === 'darwin') {
return `${process.env.HOME}/Library/Application Support/Google/Chrome`;
} else if (systemPlatform === 'win32') {
return `${process.env.LOCALAPPDATA}\\Google\\Chrome\\User Data`;
} else {
// Linux
return `${process.env.HOME}/.config/google-chrome`;
}
}
/**
* Prepares the Chrome profile by copying it to .chrome-profile directory (first run only)
* This should be called before initializing Stagehand to avoid timeouts
* @param pluginRoot The root directory of the plugin
*/
export function prepareChromeProfile(pluginRoot: string) {
const sourceUserDataDir = getChromeUserDataDir();
const tempUserDataDir = join(pluginRoot, '.chrome-profile');
// Only copy if the temp directory doesn't exist yet
if (!existsSync(tempUserDataDir)) {
const dim = '\x1b[2m';
const reset = '\x1b[0m';
// Show copying message
console.log(`${dim}Copying Chrome profile to .chrome-profile/ (this may take a minute)...${reset}`);
mkdirSync(tempUserDataDir, { recursive: true });
// Copy the Default profile directory (contains cookies, local storage, etc.)
const sourceDefaultProfile = join(sourceUserDataDir!, 'Default');
const destDefaultProfile = join(tempUserDataDir, 'Default');
if (existsSync(sourceDefaultProfile)) {
cpSync(sourceDefaultProfile, destDefaultProfile, { recursive: true });
console.log(`${dim}✓ Profile copied successfully${reset}\n`);
} else {
console.log(`${dim}No existing profile found, using fresh profile${reset}\n`);
}
}
}
// Use CDP to take screenshot directly
export async function takeScreenshot(stagehand: Stagehand, pluginRoot: string) {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-');
const screenshotDir = join(pluginRoot, 'agent/browser_screenshots');
const screenshotPath = join(screenshotDir, `screenshot-${timestamp}.png`);
// Create directory if it doesn't exist
if (!existsSync(screenshotDir)) {
mkdirSync(screenshotDir, { recursive: true });
}
const page = stagehand.context.pages()[0];
const screenshotResult = await page.screenshot({
type: 'png',
});
// Save the base64 screenshot data to file with resizing if needed
const fs = await import('fs');
const sharp = (await import('sharp')).default;
// Check image dimensions
const image = sharp(screenshotResult);
const metadata = await image.metadata();
const { width, height } = metadata;
let finalBuffer: Buffer = screenshotResult;
// Only resize if image exceeds 2000x2000
if (width && height && (width > 2000 || height > 2000)) {
finalBuffer = await sharp(screenshotResult)
.resize(2000, 2000, {
fit: 'inside',
withoutEnlargement: true
})
.png()
.toBuffer();
}
fs.writeFileSync(screenshotPath, finalBuffer);
return screenshotPath;
}

View File

@@ -0,0 +1,506 @@
#!/usr/bin/env node
import { Page, Stagehand } from '@browserbasehq/stagehand';
import { existsSync, mkdirSync, writeFileSync, readFileSync, unlinkSync } from 'fs';
import { spawn, ChildProcess } from 'child_process';
import { join, resolve, dirname } from 'path';
import { fileURLToPath } from 'url';
import { findLocalChrome, prepareChromeProfile, takeScreenshot, getAnthropicApiKey } from './browser-utils.js';
import { z } from 'zod/v4';
import dotenv from 'dotenv';
// Validate ES module environment
if (!import.meta.url) {
console.error('Error: This script must be run as an ES module');
console.error('Ensure your package.json has "type": "module" and Node.js version is 14+');
process.exit(1);
}
// Resolve plugin root directory from script location
// In production (compiled): dist/src/cli.js -> dist/src -> dist -> plugin-root
const __filename = fileURLToPath(import.meta.url);
const __dirname = dirname(__filename);
const PLUGIN_ROOT = resolve(__dirname, '..', '..');
// Load .env from plugin root directory
dotenv.config({ path: join(PLUGIN_ROOT, '.env'), quiet: true });
const apiKeyResult = getAnthropicApiKey();
if (!apiKeyResult) {
console.error('Error: No Anthropic API key found.');
console.error('\n📋 Option 1: Use your Claude subscription (RECOMMENDED)');
console.error(' If you have Claude Pro/Max, run: claude setup-token');
console.error(' This will store your subscription token in the system keychain.');
console.error('\n🔑 Option 2: Use an API key');
console.error(' Export in terminal: export ANTHROPIC_API_KEY="your-api-key"');
console.error(' Or create a .env file with: ANTHROPIC_API_KEY="your-api-key"');
process.exit(1);
}
process.env.ANTHROPIC_API_KEY = apiKeyResult.apiKey;
if (process.env.DEBUG) {
console.error(apiKeyResult.source === 'claude-code'
? '🔐 Using Claude Code subscription token from keychain'
: '🔑 Using ANTHROPIC_API_KEY from environment');
}
// Persistent browser state
let stagehandInstance: Stagehand | null = null;
let currentPage: Page | null = null;
let chromeProcess: ChildProcess | null = null;
let weStartedChrome = false; // Track if we launched Chrome vs. reused existing
async function initBrowser(): Promise<{ stagehand: Stagehand }> {
if (stagehandInstance) {
return { stagehand: stagehandInstance };
}
const chromePath = findLocalChrome();
if (!chromePath) {
throw new Error('Could not find Chrome installation');
}
const cdpPort = 9222;
const tempUserDataDir = join(PLUGIN_ROOT, '.chrome-profile');
// Check if Chrome is already running on the CDP port
let chromeReady = false;
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (response.ok) {
chromeReady = true;
console.error('Reusing existing Chrome instance on port', cdpPort);
}
} catch (error) {
// Chrome not running, need to launch it
}
// Launch Chrome if not already running
if (!chromeReady) {
chromeProcess = spawn(chromePath, [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${tempUserDataDir}`,
'--window-position=-9999,-9999', // Launch minimized off-screen
'--window-size=1250,900',
], {
stdio: 'ignore', // Ignore stdio to prevent pipe buffer blocking
detached: false,
});
// Store PID for safe cleanup later
if (chromeProcess.pid) {
const pidFilePath = join(PLUGIN_ROOT, '.chrome-pid');
writeFileSync(pidFilePath, JSON.stringify({
pid: chromeProcess.pid,
startTime: Date.now()
}));
}
// Wait for Chrome to be ready
for (let i = 0; i < 50; i++) {
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (response.ok) {
chromeReady = true;
weStartedChrome = true; // Mark that we started this Chrome instance
break;
}
} catch (error) {
// Still waiting
}
await new Promise(resolve => setTimeout(resolve, 300));
}
if (!chromeReady) {
throw new Error('Chrome failed to start');
}
}
// Get the WebSocket URL from Chrome's CDP endpoint
const versionResponse = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
const versionData = await versionResponse.json() as { webSocketDebuggerUrl: string };
const wsUrl = versionData.webSocketDebuggerUrl;
// Initialize Stagehand with the WebSocket URL
stagehandInstance = new Stagehand({
env: "LOCAL",
verbose: 0,
model: "anthropic/claude-haiku-4-5-20251001",
localBrowserLaunchOptions: {
cdpUrl: wsUrl,
},
});
await stagehandInstance.init();
currentPage = stagehandInstance.context.pages()[0];
// Wait for page to be ready
let retries = 0;
while (retries < 30) {
try {
await currentPage.evaluate('document.readyState');
break;
} catch (error) {
await new Promise(resolve => setTimeout(resolve, 100));
retries++;
}
}
// Configure downloads
const downloadsPath = join(PLUGIN_ROOT, 'agent', 'downloads');
if (!existsSync(downloadsPath)) {
mkdirSync(downloadsPath, { recursive: true });
}
const client = currentPage.mainFrame().session;
await client.send("Browser.setDownloadBehavior", {
behavior: "allow",
downloadPath: downloadsPath,
eventsEnabled: true,
});
return { stagehand: stagehandInstance };
}
async function closeBrowser() {
const cdpPort = 9222;
const pidFilePath = join(PLUGIN_ROOT, '.chrome-pid');
// First, try to close via Stagehand if we have an instance in this process
if (stagehandInstance) {
try {
await stagehandInstance.close();
} catch (error) {
console.error('Error closing Stagehand:', error instanceof Error ? error.message : String(error));
}
stagehandInstance = null;
currentPage = null;
}
// If we started Chrome in this process, kill it
if (chromeProcess && weStartedChrome) {
try {
chromeProcess.kill('SIGTERM');
// Wait briefly for graceful shutdown
await new Promise(resolve => setTimeout(resolve, 1000));
if (chromeProcess.exitCode === null) {
chromeProcess.kill('SIGKILL');
}
} catch (error) {
console.error('Error killing Chrome process:', error instanceof Error ? error.message : String(error));
}
chromeProcess = null;
weStartedChrome = false;
}
// For separate CLI invocations, use graceful CDP shutdown + PID file verification
try {
// Step 1: Try graceful shutdown via CDP
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
signal: AbortSignal.timeout(2000)
});
if (response.ok) {
// Get WebSocket URL for graceful shutdown
const versionData = await response.json() as { webSocketDebuggerUrl: string };
const wsUrl = versionData.webSocketDebuggerUrl;
// Connect and close gracefully via Stagehand
const tempStagehand = new Stagehand({
env: "LOCAL",
verbose: 0,
model: "anthropic/claude-haiku-4-5-20251001",
localBrowserLaunchOptions: {
cdpUrl: wsUrl,
},
});
await tempStagehand.init();
await tempStagehand.close();
// Wait briefly for Chrome to close
await new Promise(resolve => setTimeout(resolve, 2000));
// Step 2: Check if Chrome is still running
try {
const checkResponse = await fetch(`http://127.0.0.1:${cdpPort}/json/version`, {
signal: AbortSignal.timeout(1000)
});
// Chrome is still running, need to force close
if (checkResponse.ok) {
// Step 3: Use PID file if available for safe termination
if (existsSync(pidFilePath)) {
const pidData = JSON.parse(readFileSync(pidFilePath, 'utf8'));
const { pid } = pidData;
// Verify the process is actually Chrome before killing
const isChrome = await verifyIsChromeProcess(pid);
if (isChrome) {
if (process.platform === 'win32') {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
await execAsync(`taskkill /PID ${pid} /F`);
} else {
process.kill(pid, 'SIGKILL');
}
}
}
}
} catch {
// Chrome successfully closed
}
}
} catch (error) {
// Chrome not running or already closed
} finally {
// Clean up PID file
if (existsSync(pidFilePath)) {
try {
unlinkSync(pidFilePath);
} catch {
// Ignore cleanup errors
}
}
}
}
async function verifyIsChromeProcess(pid: number): Promise<boolean> {
try {
const { exec } = await import('child_process');
const { promisify } = await import('util');
const execAsync = promisify(exec);
if (process.platform === 'darwin' || process.platform === 'linux') {
const { stdout } = await execAsync(`ps -p ${pid} -o comm=`);
const processName = stdout.trim().toLowerCase();
return processName.includes('chrome') || processName.includes('chromium');
} else if (process.platform === 'win32') {
const { stdout } = await execAsync(`tasklist /FI "PID eq ${pid}" /FO CSV /NH`);
return stdout.toLowerCase().includes('chrome');
}
return false;
} catch {
return false;
}
}
// CLI commands
async function navigate(url: string) {
try {
const { stagehand } = await initBrowser();
await stagehand.context.pages()[0].goto(url);
const screenshotPath = await takeScreenshot(stagehand, PLUGIN_ROOT);
return {
success: true,
message: `Successfully navigated to ${url}`,
screenshot: screenshotPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
async function act(action: string) {
try {
const { stagehand } = await initBrowser();
await stagehand.act(action);
const screenshotPath = await takeScreenshot(stagehand, PLUGIN_ROOT);
return {
success: true,
message: `Successfully performed action: ${action}`,
screenshot: screenshotPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
async function extract(instruction: string, schema?: Record<string, string>) {
try {
const { stagehand } = await initBrowser();
let zodSchemaObject;
// Try to convert schema to Zod if provided
if (schema) {
try {
const zodSchema: Record<string, any> = {};
let hasValidTypes = true;
for (const [key, type] of Object.entries(schema)) {
switch (type) {
case "string":
zodSchema[key] = z.string();
break;
case "number":
zodSchema[key] = z.number();
break;
case "boolean":
zodSchema[key] = z.boolean();
break;
default:
console.error(`Warning: Unsupported schema type "${type}" for field "${key}". Proceeding without schema validation.`);
hasValidTypes = false;
break;
}
}
if (hasValidTypes && Object.keys(zodSchema).length > 0) {
zodSchemaObject = z.object(zodSchema);
}
} catch (schemaError) {
console.error('Warning: Failed to convert schema. Proceeding without schema validation:',
schemaError instanceof Error ? schemaError.message : String(schemaError));
}
}
// Extract with or without schema
const extractOptions: any = { instruction };
if (zodSchemaObject) {
extractOptions.schema = zodSchemaObject;
}
const result = await stagehand.extract(extractOptions);
const screenshotPath = await takeScreenshot(stagehand, PLUGIN_ROOT);
return {
success: true,
message: `Successfully extracted data: ${JSON.stringify(result)}`,
screenshot: screenshotPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
async function observe(query: string) {
try {
const { stagehand } = await initBrowser();
const actions = await stagehand.observe(query);
const screenshotPath = await takeScreenshot(stagehand, PLUGIN_ROOT);
return {
success: true,
message: `Successfully observed: ${actions}`,
screenshot: screenshotPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
async function screenshot() {
try {
const { stagehand } = await initBrowser();
const screenshotPath = await takeScreenshot(stagehand, PLUGIN_ROOT);
return {
success: true,
screenshot: screenshotPath
};
} catch (error) {
return {
success: false,
error: error instanceof Error ? error.message : String(error)
};
}
}
// Main CLI handler
async function main() {
// Prepare Chrome profile on first run
prepareChromeProfile(PLUGIN_ROOT);
const args = process.argv.slice(2);
const command = args[0];
try {
let result: { success: boolean; [key: string]: any };
switch (command) {
case 'navigate':
if (args.length < 2) {
throw new Error('Usage: browser navigate <url>');
}
result = await navigate(args[1]);
break;
case 'act':
if (args.length < 2) {
throw new Error('Usage: browser act "<action>"');
}
result = await act(args.slice(1).join(' '));
break;
case 'extract':
if (args.length < 2) {
throw new Error('Usage: browser extract "<instruction>" [\'{"field": "type"}\']');
}
const instruction = args[1];
const schema = args[2] ? JSON.parse(args[2]) : undefined;
result = await extract(instruction, schema);
break;
case 'observe':
if (args.length < 2) {
throw new Error('Usage: browser observe "<query>"');
}
result = await observe(args.slice(1).join(' '));
break;
case 'screenshot':
result = await screenshot();
break;
case 'close':
await closeBrowser();
result = { success: true, message: 'Browser closed' };
break;
default:
throw new Error(`Unknown command: ${command}\nAvailable commands: navigate, act, extract, observe, screenshot, close`);
}
console.log(JSON.stringify(result, null, 2));
// Browser stays open between commands - only closes on explicit 'close' command
// This allows for faster sequential operations and preserves browser state
// Exit immediately after printing result
process.exit(0);
} catch (error) {
// Close browser on error too
await closeBrowser();
console.error(JSON.stringify({
success: false,
error: error instanceof Error ? error.message : String(error)
}, null, 2));
process.exit(1);
}
}
// Handle cleanup
process.on('SIGINT', async () => {
await closeBrowser();
process.exit(0);
});
process.on('SIGTERM', async () => {
await closeBrowser();
process.exit(0);
});
main().catch(console.error);

View File

@@ -0,0 +1,190 @@
import { Stagehand } from '@browserbasehq/stagehand';
import { findLocalChrome } from './browser-utils.js';
import { spawn } from 'child_process';
import { join } from 'path';
import { writeFileSync } from 'fs';
import dotenv from 'dotenv';
dotenv.config();
interface NetworkRequest {
url: string;
method: string;
headers: Record<string, string>;
postData?: string;
timestamp: string;
}
interface NetworkResponse {
url: string;
status: number;
headers: Record<string, string>;
body?: string;
timestamp: string;
}
const capturedRequests: NetworkRequest[] = [];
const capturedResponses: NetworkResponse[] = [];
async function main() {
const url = process.argv[2] || 'https://app.circleback.ai';
const chromePath = findLocalChrome();
if (!chromePath) {
throw new Error('Could not find Chrome installation');
}
const cdpPort = 9224; // Different port
const tempUserDataDir = join(process.cwd(), '.chrome-profile'); // Use your actual profile
// Launch Chrome with your profile
const chromeProcess = spawn(chromePath, [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${tempUserDataDir}`,
], {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Wait for Chrome to be ready
let chromeReady = false;
for (let i = 0; i < 50; i++) {
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (response.ok) {
chromeReady = true;
break;
}
} catch (error) {
// Still waiting
}
await new Promise(resolve => setTimeout(resolve, 300));
}
if (!chromeReady) {
throw new Error('Chrome failed to start');
}
console.log('Chrome started with your profile...');
// Initialize Stagehand
const stagehand = new Stagehand({
env: "LOCAL",
verbose: 1,
model: "anthropic/claude-haiku-4-5-20251001",
localBrowserLaunchOptions: {
cdpUrl: `http://localhost:${cdpPort}`,
},
});
await stagehand.init();
const page = stagehand.context.pages()[0];
// Connect directly to CDP endpoint
const client = page.mainFrame().session;
// Enable network tracking
await client.send('Network.enable');
console.log('Network monitoring enabled\n');
// Listen to network requests
client.on('Network.requestWillBeSent', (params: any) => {
const request = params.request;
// Capture all API calls
if (request.url.includes('circleback.ai/api/') ||
request.url.includes('circleback.ai/trpc/')) {
capturedRequests.push({
url: request.url,
method: request.method,
headers: request.headers,
postData: request.postData,
timestamp: new Date().toISOString(),
});
console.log(`[${request.method}] ${request.url}`);
if (request.postData) {
try {
const parsed = JSON.parse(request.postData);
console.log(` Body: ${JSON.stringify(parsed, null, 2).substring(0, 300)}`);
} catch {
console.log(` Body: ${request.postData.substring(0, 200)}`);
}
}
}
});
// Listen to network responses
client.on('Network.responseReceived', async (params: any) => {
const response = params.response;
// Capture API responses
if (response.url.includes('circleback.ai/api/') ||
response.url.includes('circleback.ai/trpc/')) {
try {
const bodyResponse = await client.send<{ body: string; base64Encoded: boolean }>('Network.getResponseBody', {
requestId: params.requestId,
});
capturedResponses.push({
url: response.url,
status: response.status,
headers: response.headers,
body: bodyResponse.body,
timestamp: new Date().toISOString(),
});
console.log(` -> ${response.status}`);
if (bodyResponse.body) {
try {
const parsed = JSON.parse(bodyResponse.body);
console.log(` Response: ${JSON.stringify(parsed, null, 2).substring(0, 300)}\n`);
} catch {
console.log(` Response: ${bodyResponse.body.substring(0, 200)}\n`);
}
}
} catch (error) {
// Body might not be available
}
}
});
console.log(`Navigating to ${url}...\n`);
try {
await page.goto(url, { waitUntil: 'domcontentloaded', timeoutMs: 15000 });
console.log('Page loaded!\n');
} catch (error) {
console.log('Page load timeout, but continuing...\n');
}
// Wait for API calls
await new Promise(resolve => setTimeout(resolve, 5000));
// Try to navigate to meetings page if logged in
try {
console.log('Attempting to navigate to meetings...\n');
await page.goto('https://app.circleback.ai/meetings', { waitUntil: 'domcontentloaded', timeoutMs: 15000 });
await new Promise(resolve => setTimeout(resolve, 5000));
} catch (error) {
console.log('Could not navigate to meetings page\n');
}
// Save captured data
const outputFile = join(process.cwd(), 'network-capture-interactive.json');
writeFileSync(outputFile, JSON.stringify({
requests: capturedRequests,
responses: capturedResponses,
}, null, 2));
console.log(`\n\nCaptured ${capturedRequests.length} requests and ${capturedResponses.length} responses`);
console.log(`Saved to: ${outputFile}`);
await stagehand.close();
chromeProcess.kill();
}
main().catch(console.error);

View File

@@ -0,0 +1,170 @@
import { Stagehand } from '@browserbasehq/stagehand';
import { findLocalChrome } from './browser-utils.js';
import { spawn } from 'child_process';
import { join } from 'path';
import { writeFileSync } from 'fs';
import dotenv from 'dotenv';
dotenv.config();
interface NetworkRequest {
url: string;
method: string;
headers: Record<string, string>;
postData?: string;
timestamp: string;
}
interface NetworkResponse {
url: string;
status: number;
headers: Record<string, string>;
body?: string;
timestamp: string;
}
const capturedRequests: NetworkRequest[] = [];
const capturedResponses: NetworkResponse[] = [];
async function main() {
const url = process.argv[2];
if (!url) {
console.error('Usage: npx tsx src/network-monitor.ts <url>');
process.exit(1);
}
const chromePath = findLocalChrome();
if (!chromePath) {
throw new Error('Could not find Chrome installation');
}
const cdpPort = 9223; // Use different port to avoid conflicts
const tempUserDataDir = join(process.cwd(), '.chrome-profile-monitor');
// Launch Chrome
const chromeProcess = spawn(chromePath, [
`--remote-debugging-port=${cdpPort}`,
`--user-data-dir=${tempUserDataDir}`,
], {
stdio: ['ignore', 'pipe', 'pipe'],
detached: false,
});
// Wait for Chrome to be ready
let chromeReady = false;
for (let i = 0; i < 50; i++) {
try {
const response = await fetch(`http://127.0.0.1:${cdpPort}/json/version`);
if (response.ok) {
chromeReady = true;
break;
}
} catch (error) {
// Still waiting
}
await new Promise(resolve => setTimeout(resolve, 300));
}
if (!chromeReady) {
throw new Error('Chrome failed to start');
}
console.log('Chrome started, initializing Stagehand...');
// Initialize Stagehand
const stagehand = new Stagehand({
env: "LOCAL",
verbose: 0,
model: "anthropic/claude-haiku-4-5-20251001",
localBrowserLaunchOptions: {
cdpUrl: `http://localhost:${cdpPort}`,
},
});
await stagehand.init();
const page = stagehand.context.pages()[0];
// Connect directly to CDP endpoint
const client = stagehand.context.pages()[0].mainFrame().session;
// Enable network tracking
await client.send('Network.enable');
console.log('Network monitoring enabled');
// Listen to network requests
client.on('Network.requestWillBeSent', (params: any) => {
const request = params.request;
// Only capture API calls (not images, fonts, etc.)
if (request.url.includes('/api/') ||
request.url.includes('.json') ||
request.url.match(/graphql|trpc|rpc/i)) {
capturedRequests.push({
url: request.url,
method: request.method,
headers: request.headers,
postData: request.postData,
timestamp: new Date().toISOString(),
});
console.log(`\n[REQUEST] ${request.method} ${request.url}`);
if (request.postData) {
console.log(`[BODY] ${request.postData.substring(0, 200)}...`);
}
}
});
// Listen to network responses
client.on('Network.responseReceived', async (params: any) => {
const response = params.response;
// Only capture API responses
if (response.url.includes('/api/') ||
response.url.includes('.json') ||
response.url.match(/graphql|trpc|rpc/i)) {
try {
// Get response body
const bodyResponse = await client.send<{ body: string; base64Encoded: boolean }>('Network.getResponseBody', {
requestId: params.requestId,
});
capturedResponses.push({
url: response.url,
status: response.status,
headers: response.headers,
body: bodyResponse.body,
timestamp: new Date().toISOString(),
});
console.log(`\n[RESPONSE] ${response.status} ${response.url}`);
console.log(`[BODY] ${bodyResponse.body.substring(0, 200)}...`);
} catch (error) {
// Body might not be available for all responses
}
}
});
console.log(`\nNavigating to ${url}...`);
await page.goto(url, { waitUntil: 'networkidle' });
console.log('\nNavigation complete. Waiting 10 seconds for additional requests...');
await new Promise(resolve => setTimeout(resolve, 10000));
// Save captured data
const outputFile = join(process.cwd(), 'network-capture.json');
writeFileSync(outputFile, JSON.stringify({
requests: capturedRequests,
responses: capturedResponses,
}, null, 2));
console.log(`\n\nCaptured ${capturedRequests.length} requests and ${capturedResponses.length} responses`);
console.log(`Saved to: ${outputFile}`);
await stagehand.close();
chromeProcess.kill();
}
main().catch(console.error);

View File

@@ -0,0 +1,12 @@
{
"compilerOptions": {
"target": "ES2020",
"module": "ESNext",
"moduleResolution": "bundler",
"esModuleInterop": true,
"strict": true,
"skipLibCheck": true,
"outDir": "./dist"
},
"include": ["src/**/*.ts", "*.ts"]
}