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:
61
skills/plugins/agent-browse/README.md
Normal file
61
skills/plugins/agent-browse/README.md
Normal 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)
|
||||
241
skills/plugins/agent-browse/agent-browse.ts
Normal file
241
skills/plugins/agent-browse/agent-browse.ts
Normal 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);
|
||||
5448
skills/plugins/agent-browse/package-lock.json
generated
Normal file
5448
skills/plugins/agent-browse/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
26
skills/plugins/agent-browse/package.json
Normal file
26
skills/plugins/agent-browse/package.json
Normal 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
3814
skills/plugins/agent-browse/pnpm-lock.yaml
generated
Normal file
File diff suppressed because it is too large
Load Diff
217
skills/plugins/agent-browse/src/browser-utils.ts
Normal file
217
skills/plugins/agent-browse/src/browser-utils.ts
Normal 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;
|
||||
}
|
||||
506
skills/plugins/agent-browse/src/cli.ts
Executable file
506
skills/plugins/agent-browse/src/cli.ts
Executable 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);
|
||||
190
skills/plugins/agent-browse/src/network-monitor-interactive.ts
Normal file
190
skills/plugins/agent-browse/src/network-monitor-interactive.ts
Normal 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);
|
||||
170
skills/plugins/agent-browse/src/network-monitor.ts
Normal file
170
skills/plugins/agent-browse/src/network-monitor.ts
Normal 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);
|
||||
12
skills/plugins/agent-browse/tsconfig.json
Normal file
12
skills/plugins/agent-browse/tsconfig.json
Normal file
@@ -0,0 +1,12 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2020",
|
||||
"module": "ESNext",
|
||||
"moduleResolution": "bundler",
|
||||
"esModuleInterop": true,
|
||||
"strict": true,
|
||||
"skipLibCheck": true,
|
||||
"outDir": "./dist"
|
||||
},
|
||||
"include": ["src/**/*.ts", "*.ts"]
|
||||
}
|
||||
Reference in New Issue
Block a user