@ulpi/browse 0.1.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.
- package/BENCHMARKS.md +222 -0
- package/LICENSE +21 -0
- package/README.md +324 -0
- package/bin/browse.ts +2 -0
- package/package.json +54 -0
- package/skill/SKILL.md +301 -0
- package/src/browser-manager.ts +687 -0
- package/src/buffers.ts +81 -0
- package/src/bun.d.ts +47 -0
- package/src/cli.ts +442 -0
- package/src/commands/meta.ts +358 -0
- package/src/commands/read.ts +304 -0
- package/src/commands/write.ts +259 -0
- package/src/constants.ts +12 -0
- package/src/diff.d.ts +12 -0
- package/src/install-skill.ts +98 -0
- package/src/server.ts +325 -0
- package/src/session-manager.ts +121 -0
- package/src/snapshot.ts +497 -0
- package/src/types.ts +12 -0
|
@@ -0,0 +1,259 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Write commands — navigate and interact with pages (side effects)
|
|
3
|
+
*
|
|
4
|
+
* goto, back, forward, reload, click, fill, select, hover, type,
|
|
5
|
+
* press, scroll, wait, viewport, cookie, header, useragent
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { BrowserManager } from '../browser-manager';
|
|
9
|
+
import { resolveDevice, listDevices } from '../browser-manager';
|
|
10
|
+
import { DEFAULTS } from '../constants';
|
|
11
|
+
import * as fs from 'fs';
|
|
12
|
+
|
|
13
|
+
export async function handleWriteCommand(
|
|
14
|
+
command: string,
|
|
15
|
+
args: string[],
|
|
16
|
+
bm: BrowserManager
|
|
17
|
+
): Promise<string> {
|
|
18
|
+
const page = bm.getPage();
|
|
19
|
+
|
|
20
|
+
switch (command) {
|
|
21
|
+
case 'goto': {
|
|
22
|
+
const url = args[0];
|
|
23
|
+
if (!url) throw new Error('Usage: browse goto <url>');
|
|
24
|
+
const response = await page.goto(url, { waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
|
|
25
|
+
const status = response?.status() || 'unknown';
|
|
26
|
+
return `Navigated to ${url} (${status})`;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
case 'back': {
|
|
30
|
+
await page.goBack({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
|
|
31
|
+
return `Back → ${page.url()}`;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
case 'forward': {
|
|
35
|
+
await page.goForward({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
|
|
36
|
+
return `Forward → ${page.url()}`;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
case 'reload': {
|
|
40
|
+
await page.reload({ waitUntil: 'domcontentloaded', timeout: DEFAULTS.COMMAND_TIMEOUT_MS });
|
|
41
|
+
return `Reloaded ${page.url()}`;
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
case 'click': {
|
|
45
|
+
const selector = args[0];
|
|
46
|
+
if (!selector) throw new Error('Usage: browse click <selector>');
|
|
47
|
+
const resolved = bm.resolveRef(selector);
|
|
48
|
+
if ('locator' in resolved) {
|
|
49
|
+
await resolved.locator.click({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
50
|
+
} else {
|
|
51
|
+
await page.click(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
52
|
+
}
|
|
53
|
+
// Wait briefly for any navigation/DOM update
|
|
54
|
+
await page.waitForLoadState('domcontentloaded').catch(() => {});
|
|
55
|
+
return `Clicked ${selector} → now at ${page.url()}`;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
case 'fill': {
|
|
59
|
+
const [selector, ...valueParts] = args;
|
|
60
|
+
const value = valueParts.join(' ');
|
|
61
|
+
if (!selector || !value) throw new Error('Usage: browse fill <selector> <value>');
|
|
62
|
+
const resolved = bm.resolveRef(selector);
|
|
63
|
+
if ('locator' in resolved) {
|
|
64
|
+
await resolved.locator.fill(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
65
|
+
} else {
|
|
66
|
+
await page.fill(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
67
|
+
}
|
|
68
|
+
return `Filled ${selector}`;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
case 'select': {
|
|
72
|
+
const [selector, ...valueParts] = args;
|
|
73
|
+
const value = valueParts.join(' ');
|
|
74
|
+
if (!selector || !value) throw new Error('Usage: browse select <selector> <value>');
|
|
75
|
+
const resolved = bm.resolveRef(selector);
|
|
76
|
+
if ('locator' in resolved) {
|
|
77
|
+
await resolved.locator.selectOption(value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
78
|
+
} else {
|
|
79
|
+
await page.selectOption(resolved.selector, value, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
80
|
+
}
|
|
81
|
+
return `Selected "${value}" in ${selector}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
case 'hover': {
|
|
85
|
+
const selector = args[0];
|
|
86
|
+
if (!selector) throw new Error('Usage: browse hover <selector>');
|
|
87
|
+
const resolved = bm.resolveRef(selector);
|
|
88
|
+
if ('locator' in resolved) {
|
|
89
|
+
await resolved.locator.hover({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
90
|
+
} else {
|
|
91
|
+
await page.hover(resolved.selector, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
92
|
+
}
|
|
93
|
+
return `Hovered ${selector}`;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
case 'type': {
|
|
97
|
+
const text = args.join(' ');
|
|
98
|
+
if (!text) throw new Error('Usage: browse type <text>');
|
|
99
|
+
await page.keyboard.type(text);
|
|
100
|
+
return `Typed "${text}"`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
case 'press': {
|
|
104
|
+
const key = args[0];
|
|
105
|
+
if (!key) throw new Error('Usage: browse press <key> (e.g., Enter, Tab, Escape)');
|
|
106
|
+
await page.keyboard.press(key);
|
|
107
|
+
return `Pressed ${key}`;
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
case 'scroll': {
|
|
111
|
+
const selector = args[0];
|
|
112
|
+
if (selector) {
|
|
113
|
+
const resolved = bm.resolveRef(selector);
|
|
114
|
+
if ('locator' in resolved) {
|
|
115
|
+
await resolved.locator.scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
116
|
+
} else {
|
|
117
|
+
await page.locator(resolved.selector).scrollIntoViewIfNeeded({ timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
118
|
+
}
|
|
119
|
+
return `Scrolled ${selector} into view`;
|
|
120
|
+
}
|
|
121
|
+
await page.evaluate(() => window.scrollTo(0, document.body.scrollHeight));
|
|
122
|
+
return 'Scrolled to bottom';
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
case 'wait': {
|
|
126
|
+
const selector = args[0];
|
|
127
|
+
if (!selector) throw new Error('Usage: browse wait <selector>');
|
|
128
|
+
const timeout = args[1] ? parseInt(args[1], 10) : DEFAULTS.COMMAND_TIMEOUT_MS;
|
|
129
|
+
const resolved = bm.resolveRef(selector);
|
|
130
|
+
if ('locator' in resolved) {
|
|
131
|
+
await resolved.locator.waitFor({ state: 'visible', timeout });
|
|
132
|
+
} else {
|
|
133
|
+
await page.waitForSelector(resolved.selector, { timeout });
|
|
134
|
+
}
|
|
135
|
+
return `Element ${selector} appeared`;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
case 'viewport': {
|
|
139
|
+
const size = args[0];
|
|
140
|
+
if (!size || !size.includes('x')) throw new Error('Usage: browse viewport <WxH> (e.g., 375x812)');
|
|
141
|
+
const [w, h] = size.split('x').map(Number);
|
|
142
|
+
await bm.setViewport(w, h);
|
|
143
|
+
return `Viewport set to ${w}x${h}`;
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
case 'cookie': {
|
|
147
|
+
const cookieStr = args[0];
|
|
148
|
+
if (!cookieStr || !cookieStr.includes('=')) throw new Error('Usage: browse cookie <name>=<value>');
|
|
149
|
+
const eq = cookieStr.indexOf('=');
|
|
150
|
+
const name = cookieStr.slice(0, eq);
|
|
151
|
+
const value = cookieStr.slice(eq + 1);
|
|
152
|
+
const url = new URL(page.url());
|
|
153
|
+
await page.context().addCookies([{
|
|
154
|
+
name,
|
|
155
|
+
value,
|
|
156
|
+
domain: url.hostname,
|
|
157
|
+
path: '/',
|
|
158
|
+
}]);
|
|
159
|
+
return `Cookie set: ${name}=${value}`;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
case 'header': {
|
|
163
|
+
const headerStr = args[0];
|
|
164
|
+
if (!headerStr || !headerStr.includes(':')) throw new Error('Usage: browse header <name>:<value>');
|
|
165
|
+
const sep = headerStr.indexOf(':');
|
|
166
|
+
const name = headerStr.slice(0, sep).trim();
|
|
167
|
+
const value = headerStr.slice(sep + 1).trim();
|
|
168
|
+
await bm.setExtraHeader(name, value);
|
|
169
|
+
return `Header set: ${name}: ${value}`;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
case 'useragent': {
|
|
173
|
+
const ua = args.join(' ');
|
|
174
|
+
if (!ua) throw new Error('Usage: browse useragent <string>');
|
|
175
|
+
const prevUA = bm.getUserAgent();
|
|
176
|
+
bm.setUserAgent(ua);
|
|
177
|
+
try {
|
|
178
|
+
await bm.applyUserAgent();
|
|
179
|
+
} catch (err) {
|
|
180
|
+
// Rollback: restore previous UA so stored state matches the live context
|
|
181
|
+
bm.setUserAgent(prevUA || '');
|
|
182
|
+
throw err;
|
|
183
|
+
}
|
|
184
|
+
return `User agent set: ${ua}\nNote: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).`;
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
case 'upload': {
|
|
188
|
+
const [selector, ...filePaths] = args;
|
|
189
|
+
if (!selector || filePaths.length === 0) throw new Error('Usage: browse upload <selector> <file1> [file2] ...');
|
|
190
|
+
for (const fp of filePaths) {
|
|
191
|
+
if (!fs.existsSync(fp)) throw new Error(`File not found: ${fp}`);
|
|
192
|
+
}
|
|
193
|
+
const resolved = bm.resolveRef(selector);
|
|
194
|
+
if ('locator' in resolved) {
|
|
195
|
+
await resolved.locator.setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
196
|
+
} else {
|
|
197
|
+
await page.locator(resolved.selector).setInputFiles(filePaths, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
|
|
198
|
+
}
|
|
199
|
+
return `Uploaded ${filePaths.length} file(s) to ${selector}`;
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
case 'dialog-accept': {
|
|
203
|
+
const value = args.join(' ') || undefined;
|
|
204
|
+
bm.setAutoDialogAction('accept', value);
|
|
205
|
+
return `Dialog auto-action set to: accept${value ? ` (with value: "${value}")` : ''}`;
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
case 'dialog-dismiss': {
|
|
209
|
+
bm.setAutoDialogAction('dismiss');
|
|
210
|
+
return 'Dialog auto-action set to: dismiss';
|
|
211
|
+
}
|
|
212
|
+
|
|
213
|
+
case 'emulate': {
|
|
214
|
+
const deviceName = args.join(' ');
|
|
215
|
+
if (!deviceName) {
|
|
216
|
+
throw new Error(
|
|
217
|
+
'Usage: browse emulate <device>\n' +
|
|
218
|
+
'Examples: browse emulate iPhone 15, browse emulate Pixel 7, browse emulate reset\n' +
|
|
219
|
+
'Run "browse devices" to see all available devices.'
|
|
220
|
+
);
|
|
221
|
+
}
|
|
222
|
+
|
|
223
|
+
// Reset to desktop
|
|
224
|
+
if (deviceName.toLowerCase() === 'reset' || deviceName.toLowerCase() === 'desktop') {
|
|
225
|
+
await bm.emulateDevice(null);
|
|
226
|
+
return 'Device emulation reset to desktop (1920x1080)';
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
const device = resolveDevice(deviceName);
|
|
230
|
+
if (!device) {
|
|
231
|
+
// Find close matches
|
|
232
|
+
const all = listDevices();
|
|
233
|
+
const lower = deviceName.toLowerCase();
|
|
234
|
+
const suggestions = all.filter(d => d.toLowerCase().includes(lower)).slice(0, 5);
|
|
235
|
+
throw new Error(
|
|
236
|
+
`Unknown device: "${deviceName}"\n` +
|
|
237
|
+
(suggestions.length > 0
|
|
238
|
+
? `Did you mean: ${suggestions.join(', ')}?\n`
|
|
239
|
+
: '') +
|
|
240
|
+
'Run "browse devices" to see all available devices.'
|
|
241
|
+
);
|
|
242
|
+
}
|
|
243
|
+
|
|
244
|
+
await bm.emulateDevice(device);
|
|
245
|
+
return [
|
|
246
|
+
`Emulating: ${deviceName}`,
|
|
247
|
+
` Viewport: ${device.viewport.width}x${device.viewport.height}`,
|
|
248
|
+
` Scale: ${device.deviceScaleFactor}x`,
|
|
249
|
+
` Mobile: ${device.isMobile}`,
|
|
250
|
+
` Touch: ${device.hasTouch}`,
|
|
251
|
+
` UA: ${device.userAgent.slice(0, 80)}...`,
|
|
252
|
+
'Note: Cookies and tab URLs preserved. localStorage/sessionStorage were reset (Playwright limitation).',
|
|
253
|
+
].join('\n');
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
default:
|
|
257
|
+
throw new Error(`Unknown write command: ${command}`);
|
|
258
|
+
}
|
|
259
|
+
}
|
package/src/constants.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export const DEFAULTS = {
|
|
2
|
+
PORT_RANGE_START: 9400,
|
|
3
|
+
PORT_RANGE_END: 10400,
|
|
4
|
+
IDLE_TIMEOUT_MS: 30 * 60 * 1000, // 30 min
|
|
5
|
+
COMMAND_TIMEOUT_MS: 15_000, // 15s for navigation
|
|
6
|
+
ACTION_TIMEOUT_MS: 5_000, // 5s for clicks/fills
|
|
7
|
+
HEALTH_CHECK_TIMEOUT_MS: 2_000,
|
|
8
|
+
BUFFER_HIGH_WATER_MARK: 50_000,
|
|
9
|
+
BUFFER_FLUSH_INTERVAL_MS: 1_000,
|
|
10
|
+
NETWORK_SETTLE_MS: 5_000,
|
|
11
|
+
LOCK_STALE_THRESHOLD_MS: 15_000,
|
|
12
|
+
} as const;
|
package/src/diff.d.ts
ADDED
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
declare module 'diff' {
|
|
2
|
+
interface Change {
|
|
3
|
+
value: string;
|
|
4
|
+
added?: boolean;
|
|
5
|
+
removed?: boolean;
|
|
6
|
+
count?: number;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
export function diffLines(oldStr: string, newStr: string): Change[];
|
|
10
|
+
export function diffWords(oldStr: string, newStr: string): Change[];
|
|
11
|
+
export function diffChars(oldStr: string, newStr: string): Change[];
|
|
12
|
+
}
|
|
@@ -0,0 +1,98 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* browse install-skill — install the browse Claude Code skill into a project
|
|
3
|
+
*
|
|
4
|
+
* Copies SKILL.md to .claude/skills/browse/SKILL.md
|
|
5
|
+
* and adds browse permission rules to .claude/settings.json
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import * as fs from 'fs';
|
|
9
|
+
import * as path from 'path';
|
|
10
|
+
|
|
11
|
+
const PERMISSIONS = [
|
|
12
|
+
'Bash(browse:*)',
|
|
13
|
+
'Bash(browse goto:*)', 'Bash(browse back:*)', 'Bash(browse forward:*)',
|
|
14
|
+
'Bash(browse reload:*)', 'Bash(browse url:*)', 'Bash(browse text:*)',
|
|
15
|
+
'Bash(browse html:*)', 'Bash(browse links:*)', 'Bash(browse forms:*)',
|
|
16
|
+
'Bash(browse accessibility:*)', 'Bash(browse snapshot:*)',
|
|
17
|
+
'Bash(browse snapshot-diff:*)', 'Bash(browse click:*)',
|
|
18
|
+
'Bash(browse fill:*)', 'Bash(browse select:*)', 'Bash(browse hover:*)',
|
|
19
|
+
'Bash(browse type:*)', 'Bash(browse press:*)', 'Bash(browse scroll:*)',
|
|
20
|
+
'Bash(browse wait:*)', 'Bash(browse viewport:*)', 'Bash(browse upload:*)',
|
|
21
|
+
'Bash(browse dialog-accept:*)', 'Bash(browse dialog-dismiss:*)',
|
|
22
|
+
'Bash(browse js:*)', 'Bash(browse eval:*)', 'Bash(browse css:*)',
|
|
23
|
+
'Bash(browse attrs:*)', 'Bash(browse state:*)', 'Bash(browse dialog:*)',
|
|
24
|
+
'Bash(browse console:*)', 'Bash(browse network:*)',
|
|
25
|
+
'Bash(browse cookies:*)', 'Bash(browse storage:*)', 'Bash(browse perf:*)',
|
|
26
|
+
'Bash(browse devices:*)', 'Bash(browse emulate:*)',
|
|
27
|
+
'Bash(browse screenshot:*)', 'Bash(browse pdf:*)',
|
|
28
|
+
'Bash(browse responsive:*)', 'Bash(browse diff:*)',
|
|
29
|
+
'Bash(browse chain:*)', 'Bash(browse tabs:*)', 'Bash(browse tab:*)',
|
|
30
|
+
'Bash(browse newtab:*)', 'Bash(browse closetab:*)',
|
|
31
|
+
'Bash(browse sessions:*)', 'Bash(browse session-close:*)',
|
|
32
|
+
'Bash(browse status:*)', 'Bash(browse stop:*)', 'Bash(browse restart:*)',
|
|
33
|
+
'Bash(browse cookie:*)', 'Bash(browse header:*)',
|
|
34
|
+
'Bash(browse useragent:*)',
|
|
35
|
+
];
|
|
36
|
+
|
|
37
|
+
export function installSkill(targetDir?: string) {
|
|
38
|
+
const dir = targetDir || process.cwd();
|
|
39
|
+
|
|
40
|
+
const hasGit = fs.existsSync(path.join(dir, '.git'));
|
|
41
|
+
const hasClaude = fs.existsSync(path.join(dir, '.claude'));
|
|
42
|
+
if (!hasGit && !hasClaude) {
|
|
43
|
+
console.error(`Not a project root: ${dir}`);
|
|
44
|
+
console.error('Run from a directory with .git or .claude, or pass the path as an argument.');
|
|
45
|
+
process.exit(1);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
// 1. Copy SKILL.md
|
|
49
|
+
const skillDir = path.join(dir, '.claude', 'skills', 'browse');
|
|
50
|
+
fs.mkdirSync(skillDir, { recursive: true });
|
|
51
|
+
|
|
52
|
+
const skillSource = path.resolve(import.meta.dir, '..', 'skill', 'SKILL.md');
|
|
53
|
+
const skillDest = path.join(skillDir, 'SKILL.md');
|
|
54
|
+
|
|
55
|
+
if (!fs.existsSync(skillSource)) {
|
|
56
|
+
console.error(`SKILL.md not found at ${skillSource}`);
|
|
57
|
+
console.error('Is @ulpi/browse installed correctly?');
|
|
58
|
+
process.exit(1);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
fs.copyFileSync(skillSource, skillDest);
|
|
62
|
+
console.log(`Skill installed: ${path.relative(dir, skillDest)}`);
|
|
63
|
+
|
|
64
|
+
// 2. Update .claude/settings.json with permissions
|
|
65
|
+
const settingsPath = path.join(dir, '.claude', 'settings.json');
|
|
66
|
+
let settings: any = {};
|
|
67
|
+
|
|
68
|
+
if (fs.existsSync(settingsPath)) {
|
|
69
|
+
try {
|
|
70
|
+
settings = JSON.parse(fs.readFileSync(settingsPath, 'utf-8'));
|
|
71
|
+
} catch {
|
|
72
|
+
console.error(`Warning: could not parse ${settingsPath}, creating fresh`);
|
|
73
|
+
settings = {};
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
if (!settings.permissions) settings.permissions = {};
|
|
78
|
+
if (!Array.isArray(settings.permissions.allow)) settings.permissions.allow = [];
|
|
79
|
+
|
|
80
|
+
const existing = new Set(settings.permissions.allow);
|
|
81
|
+
let added = 0;
|
|
82
|
+
for (const perm of PERMISSIONS) {
|
|
83
|
+
if (!existing.has(perm)) {
|
|
84
|
+
settings.permissions.allow.push(perm);
|
|
85
|
+
added++;
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
fs.writeFileSync(settingsPath, JSON.stringify(settings, null, 2) + '\n');
|
|
90
|
+
|
|
91
|
+
if (added > 0) {
|
|
92
|
+
console.log(`Permissions: ${added} rules added to ${path.relative(dir, settingsPath)}`);
|
|
93
|
+
} else {
|
|
94
|
+
console.log(`Permissions: already configured`);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
console.log('\nDone. Claude Code will now use browse for web tasks automatically.');
|
|
98
|
+
}
|