@browsercash/chase 1.0.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,128 @@
1
+ /**
2
+ * Browser.cash API Client
3
+ *
4
+ * Handles creating, managing, and stopping browser sessions via the Browser.cash API.
5
+ */
6
+ const BROWSER_CASH_API_URL = process.env.BROWSER_CASH_API_URL || 'https://api.browser.cash';
7
+ /**
8
+ * Create a new browser session
9
+ */
10
+ export async function createBrowserSession(apiKey, options = {}) {
11
+ const response = await fetch(`${BROWSER_CASH_API_URL}/v1/browser/session`, {
12
+ method: 'POST',
13
+ headers: {
14
+ 'Authorization': `Bearer ${apiKey}`,
15
+ 'Content-Type': 'application/json',
16
+ },
17
+ body: JSON.stringify(options),
18
+ });
19
+ if (!response.ok) {
20
+ const error = await response.json();
21
+ throw new Error(`Browser.cash API error: ${error.error || response.statusText}`);
22
+ }
23
+ return response.json();
24
+ }
25
+ /**
26
+ * Get an existing browser session
27
+ */
28
+ export async function getBrowserSession(apiKey, sessionId) {
29
+ const response = await fetch(`${BROWSER_CASH_API_URL}/v1/browser/session?sessionId=${encodeURIComponent(sessionId)}`, {
30
+ method: 'GET',
31
+ headers: {
32
+ 'Authorization': `Bearer ${apiKey}`,
33
+ },
34
+ });
35
+ if (!response.ok) {
36
+ const error = await response.json();
37
+ throw new Error(`Browser.cash API error: ${error.error || response.statusText}`);
38
+ }
39
+ return response.json();
40
+ }
41
+ /**
42
+ * Stop a browser session
43
+ */
44
+ export async function stopBrowserSession(apiKey, sessionId) {
45
+ const response = await fetch(`${BROWSER_CASH_API_URL}/v1/browser/session?sessionId=${encodeURIComponent(sessionId)}`, {
46
+ method: 'DELETE',
47
+ headers: {
48
+ 'Authorization': `Bearer ${apiKey}`,
49
+ },
50
+ });
51
+ if (!response.ok) {
52
+ const error = await response.json();
53
+ throw new Error(`Browser.cash API error: ${error.error || response.statusText}`);
54
+ }
55
+ return response.json();
56
+ }
57
+ /**
58
+ * Wait for a session to become active and return its CDP URL
59
+ */
60
+ export async function waitForSessionReady(apiKey, sessionId, timeoutMs = 30000, pollIntervalMs = 1000) {
61
+ const startTime = Date.now();
62
+ while (Date.now() - startTime < timeoutMs) {
63
+ const session = await getBrowserSession(apiKey, sessionId);
64
+ if (session.status === 'active' && session.cdpUrl) {
65
+ return session.cdpUrl;
66
+ }
67
+ if (session.status === 'error') {
68
+ throw new Error('Browser session failed to start');
69
+ }
70
+ if (session.status === 'completed') {
71
+ throw new Error('Browser session already completed');
72
+ }
73
+ await new Promise(resolve => setTimeout(resolve, pollIntervalMs));
74
+ }
75
+ throw new Error(`Timeout waiting for browser session to become ready`);
76
+ }
77
+ /**
78
+ * Create a browser session and wait for it to be ready
79
+ */
80
+ export async function createAndWaitForSession(apiKey, options = {}, timeoutMs = 30000) {
81
+ const session = await createBrowserSession(apiKey, options);
82
+ // If session is already active with CDP URL, return immediately
83
+ if (session.status === 'active' && session.cdpUrl) {
84
+ return { session, cdpUrl: session.cdpUrl };
85
+ }
86
+ // Otherwise wait for it to be ready
87
+ const cdpUrl = await waitForSessionReady(apiKey, session.sessionId, timeoutMs);
88
+ const updatedSession = await getBrowserSession(apiKey, session.sessionId);
89
+ return { session: updatedSession, cdpUrl };
90
+ }
91
+ /**
92
+ * Helper to manage a browser session lifecycle for script execution
93
+ */
94
+ export class BrowserSessionManager {
95
+ apiKey;
96
+ session = null;
97
+ cdpUrl = null;
98
+ constructor(apiKey) {
99
+ this.apiKey = apiKey;
100
+ }
101
+ async start(options = {}) {
102
+ const result = await createAndWaitForSession(this.apiKey, options);
103
+ this.session = result.session;
104
+ this.cdpUrl = result.cdpUrl;
105
+ return this.cdpUrl;
106
+ }
107
+ async stop() {
108
+ if (this.session) {
109
+ try {
110
+ await stopBrowserSession(this.apiKey, this.session.sessionId);
111
+ }
112
+ catch {
113
+ // Ignore errors when stopping - session may already be stopped
114
+ }
115
+ this.session = null;
116
+ this.cdpUrl = null;
117
+ }
118
+ }
119
+ getSessionId() {
120
+ return this.session?.sessionId || null;
121
+ }
122
+ getCdpUrl() {
123
+ return this.cdpUrl;
124
+ }
125
+ isActive() {
126
+ return this.session?.status === 'active' && this.cdpUrl !== null;
127
+ }
128
+ }
@@ -0,0 +1,285 @@
1
+ import { spawn } from 'child_process';
2
+ import * as path from 'path';
3
+ import * as fs from 'fs';
4
+ import { getSystemPrompt } from './prompts/system-prompt.js';
5
+ /**
6
+ * Generate a unique session ID
7
+ */
8
+ function generateSessionId() {
9
+ const timestamp = Date.now().toString(36);
10
+ const random = Math.random().toString(36).substring(2, 8);
11
+ return `session-${timestamp}-${random}`;
12
+ }
13
+ /**
14
+ * Run Claude Code CLI to generate a browser automation script.
15
+ *
16
+ * Claude will:
17
+ * 1. Use snapshot commands to understand the page (for its own understanding)
18
+ * 2. Output a complete bash script in a code block
19
+ *
20
+ * We extract the script from Claude's output, not by capturing individual commands.
21
+ */
22
+ export async function runClaudeForScriptGeneration(taskPrompt, config) {
23
+ const sessionId = generateSessionId();
24
+ // Ensure sessions directory exists
25
+ if (!fs.existsSync(config.sessionsDir)) {
26
+ fs.mkdirSync(config.sessionsDir, { recursive: true });
27
+ }
28
+ // Get the system prompt and combine with task
29
+ const systemPrompt = getSystemPrompt(config.cdpUrl);
30
+ const fullPrompt = `${systemPrompt}
31
+
32
+ ## Your Task
33
+
34
+ ${taskPrompt}`;
35
+ // Write full prompt to a file
36
+ const promptFile = path.join(config.sessionsDir, `${sessionId}-prompt.txt`);
37
+ fs.writeFileSync(promptFile, fullPrompt);
38
+ return new Promise((resolve) => {
39
+ let output = '';
40
+ let errorOutput = '';
41
+ const env = {
42
+ ...process.env,
43
+ CDP_URL: config.cdpUrl,
44
+ };
45
+ console.log(`\n[claude-gen] Starting Claude Code...`);
46
+ console.log(`[claude-gen] Session ID: ${sessionId}`);
47
+ // Use stream-json for verbose output, allow Bash for snapshots
48
+ const shellCmd = `cat "${promptFile}" | claude -p --model ${config.model} --max-turns ${config.maxTurns} --allowedTools "Bash" --output-format stream-json --verbose`;
49
+ const claude = spawn('bash', ['-c', shellCmd], {
50
+ env,
51
+ stdio: ['ignore', 'pipe', 'pipe'],
52
+ detached: false,
53
+ });
54
+ claude.stdout?.on('data', (data) => {
55
+ const text = data.toString();
56
+ output += text;
57
+ process.stdout.write(text);
58
+ });
59
+ claude.stderr?.on('data', (data) => {
60
+ const text = data.toString();
61
+ errorOutput += text;
62
+ process.stderr.write(text);
63
+ });
64
+ claude.on('close', (code) => {
65
+ // Clean up temp files
66
+ cleanup(promptFile);
67
+ // Extract the script from Claude's output
68
+ const script = extractScriptFromOutput(output);
69
+ if (script) {
70
+ console.log(`\n[claude-gen] Successfully extracted script from Claude's output`);
71
+ }
72
+ else {
73
+ console.log(`\n[claude-gen] WARNING: Could not extract script from Claude's output`);
74
+ }
75
+ resolve({
76
+ success: code === 0 && script !== null,
77
+ sessionId,
78
+ output,
79
+ script,
80
+ error: code !== 0 ? (errorOutput || `Exit code: ${code}`) : undefined,
81
+ });
82
+ });
83
+ claude.on('error', (err) => {
84
+ cleanup(promptFile);
85
+ resolve({
86
+ success: false,
87
+ sessionId,
88
+ output,
89
+ script: null,
90
+ error: err.message,
91
+ });
92
+ });
93
+ });
94
+ }
95
+ function cleanup(...files) {
96
+ for (const file of files) {
97
+ try {
98
+ if (fs.existsSync(file)) {
99
+ fs.unlinkSync(file);
100
+ }
101
+ }
102
+ catch {
103
+ // Ignore cleanup errors
104
+ }
105
+ }
106
+ }
107
+ /**
108
+ * Extract a bash script from Claude's output.
109
+ * Looks for code blocks containing agent-browser commands.
110
+ */
111
+ function extractScriptFromOutput(output) {
112
+ // First, try to extract text content from stream-json format
113
+ const textContent = extractTextFromStreamJson(output);
114
+ // Look for bash code blocks in the extracted text
115
+ // Use new RegExp to avoid backtick escaping issues
116
+ const codeBlockPatterns = [
117
+ new RegExp('```bash\\n([\\s\\S]*?)```', 'g'),
118
+ new RegExp('```sh\\n([\\s\\S]*?)```', 'g'),
119
+ new RegExp('```shell\\n([\\s\\S]*?)```', 'g'),
120
+ new RegExp('```\\n(#!/bin/bash[\\s\\S]*?)```', 'g'),
121
+ ];
122
+ let bestScript = null;
123
+ let bestScore = 0;
124
+ for (const pattern of codeBlockPatterns) {
125
+ let match;
126
+ while ((match = pattern.exec(textContent)) !== null) {
127
+ const script = match[1].trim();
128
+ const score = scoreScript(script);
129
+ if (score > bestScore) {
130
+ bestScore = score;
131
+ bestScript = script;
132
+ }
133
+ }
134
+ }
135
+ if (bestScript) {
136
+ // Ensure it has shebang
137
+ if (!bestScript.startsWith('#!/')) {
138
+ bestScript = '#!/bin/bash\nset -e\n\nCDP="${CDP_URL:?Required}"\n\n' + bestScript;
139
+ }
140
+ return bestScript;
141
+ }
142
+ // Fallback: look for agent-browser commands and construct a script
143
+ const commands = extractAgentBrowserCommands(textContent);
144
+ if (commands.length > 0) {
145
+ // Filter out snapshot commands
146
+ const replayableCommands = commands.filter(cmd => !cmd.includes('snapshot'));
147
+ if (replayableCommands.length > 0) {
148
+ return constructScriptFromCommands(replayableCommands);
149
+ }
150
+ }
151
+ return null;
152
+ }
153
+ /**
154
+ * Extract text content from stream-json format output
155
+ */
156
+ function extractTextFromStreamJson(output) {
157
+ const lines = output.split('\n');
158
+ const textParts = [];
159
+ for (const line of lines) {
160
+ if (!line.trim().startsWith('{'))
161
+ continue;
162
+ try {
163
+ const json = JSON.parse(line);
164
+ // Extract text from assistant messages
165
+ if (json.type === 'assistant' && json.message?.content) {
166
+ for (const block of json.message.content) {
167
+ if (block.type === 'text') {
168
+ textParts.push(block.text);
169
+ }
170
+ }
171
+ }
172
+ // Also check for result messages
173
+ if (json.type === 'result' && json.result) {
174
+ textParts.push(json.result);
175
+ }
176
+ }
177
+ catch {
178
+ // Not valid JSON, might be raw text
179
+ textParts.push(line);
180
+ }
181
+ }
182
+ return textParts.join('\n');
183
+ }
184
+ /**
185
+ * Score a script based on how complete it looks
186
+ */
187
+ function scoreScript(script) {
188
+ let score = 0;
189
+ // Must have agent-browser commands
190
+ if (!script.includes('agent-browser'))
191
+ return 0;
192
+ // Bonus for having shebang
193
+ if (script.includes('#!/bin/bash'))
194
+ score += 10;
195
+ // Bonus for having CDP variable
196
+ if (script.includes('CDP=') || script.includes('$CDP'))
197
+ score += 10;
198
+ // Bonus for having open command
199
+ if (script.includes('open "http'))
200
+ score += 20;
201
+ // Bonus for having eval command (data extraction)
202
+ if (script.includes('eval "'))
203
+ score += 20;
204
+ // Bonus for having FINAL RESULTS section
205
+ if (script.includes('FINAL RESULTS') || script.includes('echo "$'))
206
+ score += 10;
207
+ // Penalty for snapshot commands (shouldn't be in final script)
208
+ const snapshotCount = (script.match(/snapshot/g) || []).length;
209
+ score -= snapshotCount * 5;
210
+ // Penalty for @eN refs (shouldn't be in final script)
211
+ const refCount = (script.match(/@e\d+/g) || []).length;
212
+ score -= refCount * 10;
213
+ // Count useful commands
214
+ const openCount = (script.match(/\bopen\s+"/g) || []).length;
215
+ const evalCount = (script.match(/\beval\s+"/g) || []).length;
216
+ score += openCount * 5 + evalCount * 10;
217
+ return score;
218
+ }
219
+ /**
220
+ * Extract agent-browser commands from text
221
+ */
222
+ function extractAgentBrowserCommands(text) {
223
+ const commands = [];
224
+ const lines = text.split('\n');
225
+ for (const line of lines) {
226
+ const trimmed = line.trim();
227
+ // Skip comment lines
228
+ if (trimmed.startsWith('#'))
229
+ continue;
230
+ // Look for agent-browser commands
231
+ if (trimmed.includes('agent-browser') && trimmed.includes('--cdp')) {
232
+ // Extract the command portion
233
+ const match = trimmed.match(/(agent-browser\s+--cdp\s+[^\n]+)/);
234
+ if (match && !commands.includes(match[1])) {
235
+ commands.push(match[1]);
236
+ }
237
+ }
238
+ }
239
+ return commands;
240
+ }
241
+ /**
242
+ * Construct a script from extracted commands (fallback)
243
+ */
244
+ function constructScriptFromCommands(commands) {
245
+ const lines = [
246
+ '#!/bin/bash',
247
+ 'set -e',
248
+ '',
249
+ '# Generated by claude-gen (fallback mode)',
250
+ 'CDP="${CDP_URL:?Required: CDP_URL}"',
251
+ '',
252
+ ];
253
+ for (const cmd of commands) {
254
+ // Normalize CDP variable
255
+ let normalized = cmd
256
+ .replace(/"\$CDP_URL"/g, '"$CDP"')
257
+ .replace(/\$CDP_URL/g, '$CDP');
258
+ lines.push(normalized);
259
+ // Add sleep after navigation
260
+ if (cmd.includes(' open ')) {
261
+ lines.push('sleep 2');
262
+ }
263
+ }
264
+ lines.push('');
265
+ lines.push('echo "Script completed"');
266
+ return lines.join('\n');
267
+ }
268
+ // Keep the old function for backwards compatibility, but have it use the new one
269
+ export async function runClaudeWithOutputParsing(taskPrompt, config) {
270
+ const result = await runClaudeForScriptGeneration(taskPrompt, config);
271
+ // Extract commands from script for backwards compatibility
272
+ const commands = [];
273
+ if (result.script) {
274
+ const lines = result.script.split('\n');
275
+ for (const line of lines) {
276
+ if (line.includes('agent-browser') && !line.trim().startsWith('#')) {
277
+ commands.push(line.trim());
278
+ }
279
+ }
280
+ }
281
+ return {
282
+ ...result,
283
+ commands,
284
+ };
285
+ }
@@ -0,0 +1,104 @@
1
+ #!/usr/bin/env node
2
+ /**
3
+ * Chase CLI Installer
4
+ *
5
+ * Usage:
6
+ * npx chase-install # Install skill + show MCP setup
7
+ * npx chase-install --skill # Install skill only
8
+ * npx chase-install --mcp # Show MCP setup only
9
+ */
10
+ import * as fs from 'fs';
11
+ import * as path from 'path';
12
+ import * as https from 'https';
13
+ import * as os from 'os';
14
+ const SKILL_URL = 'https://raw.githubusercontent.com/alexander-spring/chase/main/skill/SKILL.md';
15
+ const MCP_URL = 'https://chase-api-gth2quoxyq-uc.a.run.app/mcp';
16
+ function downloadFile(url, dest) {
17
+ return new Promise((resolve, reject) => {
18
+ const file = fs.createWriteStream(dest);
19
+ https.get(url, (response) => {
20
+ if (response.statusCode === 302 || response.statusCode === 301) {
21
+ // Follow redirect
22
+ https.get(response.headers.location, (res) => {
23
+ res.pipe(file);
24
+ file.on('finish', () => { file.close(); resolve(); });
25
+ }).on('error', reject);
26
+ }
27
+ else {
28
+ response.pipe(file);
29
+ file.on('finish', () => { file.close(); resolve(); });
30
+ }
31
+ }).on('error', reject);
32
+ });
33
+ }
34
+ async function installSkill() {
35
+ const skillDir = path.join(os.homedir(), '.claude', 'skills', 'chase');
36
+ const skillPath = path.join(skillDir, 'SKILL.md');
37
+ console.log('Installing chase skill...');
38
+ // Create directory
39
+ fs.mkdirSync(skillDir, { recursive: true });
40
+ // Download skill
41
+ await downloadFile(SKILL_URL, skillPath);
42
+ console.log(`✓ Skill installed to ${skillPath}`);
43
+ }
44
+ function showMcpSetup() {
45
+ console.log(`
46
+ MCP Server Setup
47
+ ================
48
+
49
+ Option 1: Hosted HTTP (Recommended)
50
+ -----------------------------------
51
+ claude mcp add --transport http chase ${MCP_URL} -H "x-api-key: YOUR_API_KEY"
52
+
53
+ Option 2: Local stdio
54
+ ---------------------
55
+ git clone https://github.com/alexander-spring/chase.git
56
+ cd chase/mcp-server && npm install && npm run build
57
+ claude mcp add chase node ./dist/index.js -e BROWSER_CASH_API_KEY=YOUR_KEY
58
+
59
+ Claude Desktop Config
60
+ ---------------------
61
+ Add to ~/Library/Application Support/Claude/claude_desktop_config.json:
62
+
63
+ {
64
+ "mcpServers": {
65
+ "chase": {
66
+ "transport": "http",
67
+ "url": "${MCP_URL}",
68
+ "headers": {
69
+ "x-api-key": "YOUR_API_KEY"
70
+ }
71
+ }
72
+ }
73
+ }
74
+ `);
75
+ }
76
+ async function main() {
77
+ const args = process.argv.slice(2);
78
+ const skillOnly = args.includes('--skill');
79
+ const mcpOnly = args.includes('--mcp');
80
+ console.log(`
81
+ ╔═══════════════════════════════════════════╗
82
+ ║ Chase Browser Automation ║
83
+ ╚═══════════════════════════════════════════╝
84
+ `);
85
+ if (!mcpOnly) {
86
+ try {
87
+ await installSkill();
88
+ }
89
+ catch (err) {
90
+ console.error('Failed to install skill:', err instanceof Error ? err.message : err);
91
+ }
92
+ }
93
+ if (!skillOnly) {
94
+ showMcpSetup();
95
+ }
96
+ console.log(`
97
+ Get your API key at: https://browser.cash
98
+
99
+ Quick Start:
100
+ export BROWSER_CASH_API_KEY="your-key"
101
+ # Then ask Claude to extract data from any website!
102
+ `);
103
+ }
104
+ main().catch(console.error);