@ulpi/browse 0.3.0 → 0.5.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/LICENSE +1 -1
- package/README.md +3 -1
- package/package.json +3 -2
- package/skill/SKILL.md +22 -7
- package/src/browser-manager.ts +75 -0
- package/src/cli.ts +2 -1
- package/src/commands/meta.ts +45 -12
- package/src/server.ts +1 -1
package/LICENSE
CHANGED
package/README.md
CHANGED
|
@@ -116,6 +116,7 @@ Every detected element gets a ref. `browse click @e3` just works.
|
|
|
116
116
|
| State persistence | Not available | `state save\|load` |
|
|
117
117
|
| Credential vault | Not available | `auth save\|login\|list` |
|
|
118
118
|
| HAR recording | Not available | `har start\|stop` |
|
|
119
|
+
| Video recording | Not available | `video start [dir]\|stop\|status` |
|
|
119
120
|
| Clipboard access | Not available | `clipboard [write <text>]` |
|
|
120
121
|
| Element finding | Not available | `find role\|text\|label\|placeholder\|testid` |
|
|
121
122
|
| DevTools inspect | Not available | `inspect` |
|
|
@@ -291,7 +292,7 @@ echo '[["goto","https://example.com"],["text"]]' | browse chain
|
|
|
291
292
|
`state save [name]` | `state load [name]` | `state list` | `state show [name]` | `auth save <name> <url> <user> <pass>` | `auth login <name>` | `auth list` | `auth delete <name>`
|
|
292
293
|
|
|
293
294
|
### Recording
|
|
294
|
-
`har start` | `har stop [path]`
|
|
295
|
+
`har start` | `har stop [path]` | `video start [dir]` | `video stop` | `video status`
|
|
295
296
|
|
|
296
297
|
### Debug
|
|
297
298
|
`inspect` — open DevTools debugger (requires `BROWSE_DEBUG_PORT`).
|
|
@@ -384,6 +385,7 @@ Inspired by and originally derived from the `/browse` skill in [gstack](https://
|
|
|
384
385
|
- `offline on/off` — offline mode toggle
|
|
385
386
|
- `state save/load` — persist and restore cookies + localStorage (all origins)
|
|
386
387
|
- `har start/stop` — HAR recording and export
|
|
388
|
+
- `video start/stop/status` — video recording (WebM, compositor-level, works with remote CDP)
|
|
387
389
|
- `screenshot-diff` — pixel-level visual regression testing
|
|
388
390
|
- `find role/text/label/placeholder/testid` — semantic element locators
|
|
389
391
|
|
package/package.json
CHANGED
|
@@ -1,11 +1,12 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@ulpi/browse",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.5.0",
|
|
4
4
|
"repository": {
|
|
5
5
|
"type": "git",
|
|
6
6
|
"url": "https://github.com/ulpi-io/browse"
|
|
7
7
|
},
|
|
8
8
|
"dependencies": {
|
|
9
|
+
"@ulpi/browse": "^0.3.0",
|
|
9
10
|
"diff": "^7.0.0",
|
|
10
11
|
"playwright": "^1.58.2"
|
|
11
12
|
},
|
|
@@ -38,7 +39,7 @@
|
|
|
38
39
|
"access": "public"
|
|
39
40
|
},
|
|
40
41
|
"scripts": {
|
|
41
|
-
"build": "bun build --compile --external electron --external chromium-bidi src/cli.ts --outfile dist/browse",
|
|
42
|
+
"build": "bun build --compile --external electron --external chromium-bidi src/cli.ts --outfile dist/browse && rm -f .*.bun-build",
|
|
42
43
|
"build:all": "bash scripts/build-all.sh",
|
|
43
44
|
"dev": "bun run src/cli.ts",
|
|
44
45
|
"server": "bun run src/server.ts",
|
package/skill/SKILL.md
CHANGED
|
@@ -1,19 +1,19 @@
|
|
|
1
1
|
---
|
|
2
2
|
name: browse
|
|
3
|
-
version: 2.
|
|
3
|
+
version: 2.3.0
|
|
4
4
|
description: |
|
|
5
|
-
Fast web browsing for
|
|
5
|
+
Fast web browsing for AI coding agents via persistent headless Chromium daemon. Navigate to any URL,
|
|
6
6
|
read page content, click elements, fill forms, run JavaScript, take screenshots,
|
|
7
7
|
inspect CSS/DOM, capture console/network logs, and more. ~100ms per command after
|
|
8
|
-
first call.
|
|
9
|
-
|
|
8
|
+
first call. Works with Claude Code, Cursor, Cline, Windsurf, and any agent that can run Bash.
|
|
9
|
+
No MCP, no Chrome extension — just fast CLI.
|
|
10
10
|
allowed-tools:
|
|
11
11
|
- Bash
|
|
12
12
|
- Read
|
|
13
13
|
|
|
14
14
|
---
|
|
15
15
|
|
|
16
|
-
# browse: Persistent Browser for
|
|
16
|
+
# browse: Persistent Browser for AI Coding Agents
|
|
17
17
|
|
|
18
18
|
Persistent headless Chromium daemon. First call auto-starts the server (~3s).
|
|
19
19
|
Every subsequent call: ~100-200ms. Auto-shuts down after 30 min idle.
|
|
@@ -74,7 +74,7 @@ If the file is missing or does not contain browse permission rules in `permissio
|
|
|
74
74
|
"Bash(browse newtab:*)", "Bash(browse closetab:*)",
|
|
75
75
|
"Bash(browse frame:*)",
|
|
76
76
|
"Bash(browse sessions:*)", "Bash(browse session-close:*)",
|
|
77
|
-
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)",
|
|
77
|
+
"Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)", "Bash(browse video:*)",
|
|
78
78
|
"Bash(browse route:*)", "Bash(browse offline:*)",
|
|
79
79
|
"Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
|
|
80
80
|
"Bash(browse cookie:*)", "Bash(browse header:*)",
|
|
@@ -197,6 +197,12 @@ browse har start
|
|
|
197
197
|
browse goto https://example.com
|
|
198
198
|
browse har stop ./recording.har
|
|
199
199
|
|
|
200
|
+
# Video recording
|
|
201
|
+
browse video start ./videos
|
|
202
|
+
browse goto https://example.com
|
|
203
|
+
browse click @e3
|
|
204
|
+
browse video stop
|
|
205
|
+
|
|
200
206
|
# Device emulation
|
|
201
207
|
browse emulate iphone
|
|
202
208
|
browse emulate reset
|
|
@@ -325,7 +331,8 @@ browse clipboard write <text> Write text to system clipboard
|
|
|
325
331
|
|
|
326
332
|
### Visual
|
|
327
333
|
```
|
|
328
|
-
browse screenshot [path]
|
|
334
|
+
browse screenshot [path] Viewport screenshot (default: .browse/sessions/{id}/screenshot.png)
|
|
335
|
+
browse screenshot --full [path] Full-page screenshot (entire scrollable page)
|
|
329
336
|
browse screenshot --annotate [path] Screenshot with numbered badges + legend
|
|
330
337
|
browse pdf [path] Save as PDF
|
|
331
338
|
browse responsive [prefix] Screenshots at mobile/tablet/desktop
|
|
@@ -394,6 +401,13 @@ browse har start Start recording network traffic
|
|
|
394
401
|
browse har stop [path] Stop and save HAR file
|
|
395
402
|
```
|
|
396
403
|
|
|
404
|
+
### Video recording
|
|
405
|
+
```
|
|
406
|
+
browse video start [dir] Start recording video (WebM, compositor-level)
|
|
407
|
+
browse video stop Stop recording and save video files
|
|
408
|
+
browse video status Check if recording is active
|
|
409
|
+
```
|
|
410
|
+
|
|
397
411
|
### Server management
|
|
398
412
|
```
|
|
399
413
|
browse status Server health, uptime, session count
|
|
@@ -454,6 +468,7 @@ browse inspect Open DevTools (requires BROWSE_DEBUG_PORT)
|
|
|
454
468
|
| Save/restore session | `state save mysite` / `state load mysite` |
|
|
455
469
|
| Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
|
|
456
470
|
| Record network | `har start` → browse around → `har stop ./out.har` |
|
|
471
|
+
| Record video | `video start ./vids` → browse around → `video stop` |
|
|
457
472
|
| Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
|
|
458
473
|
| Multi-step flow | `echo '[...]' \| browse chain` |
|
|
459
474
|
| Secure browsing | `--allowed-domains example.com goto https://example.com` |
|
package/src/browser-manager.ts
CHANGED
|
@@ -167,6 +167,9 @@ export class BrowserManager {
|
|
|
167
167
|
// ─── HAR Recording ────────────────────────────────────────
|
|
168
168
|
private harRecording: HarRecording | null = null;
|
|
169
169
|
|
|
170
|
+
// ─── Video Recording ────────────────────────────────────────
|
|
171
|
+
private videoRecording: { dir: string; startedAt: number } | null = null;
|
|
172
|
+
|
|
170
173
|
// ─── Init Script (domain filter JS injection) ─────────────
|
|
171
174
|
private initScript: string | null = null;
|
|
172
175
|
|
|
@@ -553,6 +556,17 @@ export class BrowserManager {
|
|
|
553
556
|
private async recreateContext(contextOptions: Record<string, any>): Promise<void> {
|
|
554
557
|
if (!this.browser) return;
|
|
555
558
|
|
|
559
|
+
// Auto-inject recordVideo when video recording is active (so emulateDevice/applyUserAgent pass it through)
|
|
560
|
+
if (this.videoRecording && !contextOptions.recordVideo) {
|
|
561
|
+
contextOptions = {
|
|
562
|
+
...contextOptions,
|
|
563
|
+
recordVideo: {
|
|
564
|
+
dir: this.videoRecording.dir,
|
|
565
|
+
size: contextOptions.viewport || this.currentDevice?.viewport || { width: 1920, height: 1080 },
|
|
566
|
+
},
|
|
567
|
+
};
|
|
568
|
+
}
|
|
569
|
+
|
|
556
570
|
// Save all tab URLs and which tab was active
|
|
557
571
|
const tabUrls: Array<{ id: number; url: string; active: boolean }> = [];
|
|
558
572
|
for (const [id, page] of this.pages) {
|
|
@@ -766,6 +780,67 @@ export class BrowserManager {
|
|
|
766
780
|
return this.harRecording;
|
|
767
781
|
}
|
|
768
782
|
|
|
783
|
+
// ─── Video Recording ──────────────────────────────────────
|
|
784
|
+
|
|
785
|
+
async startVideoRecording(dir: string): Promise<void> {
|
|
786
|
+
if (this.videoRecording) throw new Error('Video recording already active');
|
|
787
|
+
const fs = await import('fs');
|
|
788
|
+
fs.mkdirSync(dir, { recursive: true });
|
|
789
|
+
|
|
790
|
+
this.videoRecording = { dir, startedAt: Date.now() };
|
|
791
|
+
const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
|
|
792
|
+
await this.recreateContext({
|
|
793
|
+
viewport,
|
|
794
|
+
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
795
|
+
...(this.currentDevice ? {
|
|
796
|
+
deviceScaleFactor: this.currentDevice.deviceScaleFactor,
|
|
797
|
+
isMobile: this.currentDevice.isMobile,
|
|
798
|
+
hasTouch: this.currentDevice.hasTouch,
|
|
799
|
+
} : {}),
|
|
800
|
+
recordVideo: { dir, size: viewport },
|
|
801
|
+
});
|
|
802
|
+
}
|
|
803
|
+
|
|
804
|
+
async stopVideoRecording(): Promise<{ dir: string; startedAt: number; paths: string[] } | null> {
|
|
805
|
+
if (!this.videoRecording) return null;
|
|
806
|
+
|
|
807
|
+
const recording = this.videoRecording;
|
|
808
|
+
// Collect video objects before pages are closed by recreateContext
|
|
809
|
+
const videos: Array<{ video: any; tabId: number }> = [];
|
|
810
|
+
for (const [id, page] of this.pages) {
|
|
811
|
+
const video = page.video();
|
|
812
|
+
if (video) videos.push({ video, tabId: id });
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
// Clear state BEFORE recreateContext so auto-injection doesn't add recordVideo
|
|
816
|
+
this.videoRecording = null;
|
|
817
|
+
|
|
818
|
+
const viewport = this.currentDevice?.viewport || { width: 1920, height: 1080 };
|
|
819
|
+
await this.recreateContext({
|
|
820
|
+
viewport,
|
|
821
|
+
...(this.customUserAgent ? { userAgent: this.customUserAgent } : {}),
|
|
822
|
+
...(this.currentDevice ? {
|
|
823
|
+
deviceScaleFactor: this.currentDevice.deviceScaleFactor,
|
|
824
|
+
isMobile: this.currentDevice.isMobile,
|
|
825
|
+
hasTouch: this.currentDevice.hasTouch,
|
|
826
|
+
} : {}),
|
|
827
|
+
});
|
|
828
|
+
|
|
829
|
+
// Save videos with predictable names (saveAs works for both local and remote CDP)
|
|
830
|
+
const paths: string[] = [];
|
|
831
|
+
for (const { video, tabId } of videos) {
|
|
832
|
+
const target = `${recording.dir}/tab-${tabId}.webm`;
|
|
833
|
+
await video.saveAs(target);
|
|
834
|
+
paths.push(target);
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
return { dir: recording.dir, startedAt: recording.startedAt, paths };
|
|
838
|
+
}
|
|
839
|
+
|
|
840
|
+
getVideoRecording(): { dir: string; startedAt: number } | null {
|
|
841
|
+
return this.videoRecording;
|
|
842
|
+
}
|
|
843
|
+
|
|
769
844
|
// ─── Init Script ───────────────────────────────────────────
|
|
770
845
|
setInitScript(script: string): void {
|
|
771
846
|
this.initScript = script;
|
package/src/cli.ts
CHANGED
|
@@ -605,13 +605,14 @@ Inspection: js <expr> | eval <file> | css <sel> <prop> | attrs <sel>
|
|
|
605
605
|
element-state <sel> | console [--clear] | network [--clear]
|
|
606
606
|
cookies | storage [set <k> <v>] | perf
|
|
607
607
|
value <sel> | count <sel> | clipboard [write <text>]
|
|
608
|
-
Visual: screenshot [path] | pdf [path] | responsive [prefix]
|
|
608
|
+
Visual: screenshot [path] [--full] [--annotate] | pdf [path] | responsive [prefix]
|
|
609
609
|
Snapshot: snapshot [-i] [-c] [-C] [-d N] [-s sel]
|
|
610
610
|
Find: find role|text|label|placeholder|testid <query> [name]
|
|
611
611
|
Compare: diff <url1> <url2> | screenshot-diff <baseline> [current]
|
|
612
612
|
Multi-step: chain (reads JSON from stdin)
|
|
613
613
|
Network: offline [on|off] | route <pattern> block|fulfill
|
|
614
614
|
Recording: har start | har stop [path]
|
|
615
|
+
video start [dir] | video stop | video status
|
|
615
616
|
Tabs: tabs | tab <id> | newtab [url] | closetab [id]
|
|
616
617
|
Frames: frame <sel> | frame main
|
|
617
618
|
Sessions: sessions | session-close <id>
|
package/src/commands/meta.ts
CHANGED
|
@@ -218,7 +218,8 @@ export async function handleMetaCommand(
|
|
|
218
218
|
case 'screenshot': {
|
|
219
219
|
const page = bm.getPage();
|
|
220
220
|
const annotate = args.includes('--annotate');
|
|
221
|
-
const
|
|
221
|
+
const isFullPage = args.includes('--full');
|
|
222
|
+
const filteredArgs = args.filter(a => a !== '--annotate' && a !== '--full');
|
|
222
223
|
const screenshotPath = filteredArgs[0] || (currentSession ? `${currentSession.outputDir}/screenshot.png` : `${LOCAL_DIR}/browse-screenshot.png`);
|
|
223
224
|
|
|
224
225
|
if (annotate) {
|
|
@@ -278,7 +279,7 @@ export async function handleMetaCommand(
|
|
|
278
279
|
document.body.appendChild(container);
|
|
279
280
|
}, badges);
|
|
280
281
|
|
|
281
|
-
await page.screenshot({ path: screenshotPath, fullPage:
|
|
282
|
+
await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
|
|
282
283
|
} finally {
|
|
283
284
|
await page.evaluate(() => {
|
|
284
285
|
document.getElementById('__browse_annotate__')?.remove();
|
|
@@ -288,7 +289,7 @@ export async function handleMetaCommand(
|
|
|
288
289
|
return `Screenshot saved: ${screenshotPath}\n\nLegend:\n${legend.join('\n')}`;
|
|
289
290
|
}
|
|
290
291
|
|
|
291
|
-
await page.screenshot({ path: screenshotPath, fullPage:
|
|
292
|
+
await page.screenshot({ path: screenshotPath, fullPage: isFullPage });
|
|
292
293
|
return `Screenshot saved: ${screenshotPath}`;
|
|
293
294
|
}
|
|
294
295
|
|
|
@@ -481,14 +482,16 @@ export async function handleMetaCommand(
|
|
|
481
482
|
|
|
482
483
|
// ─── Screenshot Diff ──────────────────────────────
|
|
483
484
|
case 'screenshot-diff': {
|
|
484
|
-
const
|
|
485
|
-
|
|
485
|
+
const isFullPageDiff = args.includes('--full');
|
|
486
|
+
const diffArgs = args.filter(a => a !== '--full');
|
|
487
|
+
const baseline = diffArgs[0];
|
|
488
|
+
if (!baseline) throw new Error('Usage: browse screenshot-diff <baseline> [current] [--threshold 0.1] [--full]');
|
|
486
489
|
if (!fs.existsSync(baseline)) throw new Error(`Baseline file not found: ${baseline}`);
|
|
487
490
|
|
|
488
491
|
let thresholdPct = 0.1;
|
|
489
|
-
const threshIdx =
|
|
490
|
-
if (threshIdx !== -1 &&
|
|
491
|
-
thresholdPct = parseFloat(
|
|
492
|
+
const threshIdx = diffArgs.indexOf('--threshold');
|
|
493
|
+
if (threshIdx !== -1 && diffArgs[threshIdx + 1]) {
|
|
494
|
+
thresholdPct = parseFloat(diffArgs[threshIdx + 1]);
|
|
492
495
|
}
|
|
493
496
|
|
|
494
497
|
const baselineBuffer = fs.readFileSync(baseline);
|
|
@@ -496,16 +499,16 @@ export async function handleMetaCommand(
|
|
|
496
499
|
// Find optional current image path: any non-flag arg after baseline
|
|
497
500
|
let currentBuffer: Buffer;
|
|
498
501
|
let currentPath: string | undefined;
|
|
499
|
-
for (let i = 1; i <
|
|
500
|
-
if (
|
|
501
|
-
if (!
|
|
502
|
+
for (let i = 1; i < diffArgs.length; i++) {
|
|
503
|
+
if (diffArgs[i] === '--threshold') { i++; continue; }
|
|
504
|
+
if (!diffArgs[i].startsWith('--')) { currentPath = diffArgs[i]; break; }
|
|
502
505
|
}
|
|
503
506
|
if (currentPath) {
|
|
504
507
|
if (!fs.existsSync(currentPath)) throw new Error(`Current screenshot not found: ${currentPath}`);
|
|
505
508
|
currentBuffer = fs.readFileSync(currentPath);
|
|
506
509
|
} else {
|
|
507
510
|
const page = bm.getPage();
|
|
508
|
-
currentBuffer = await page.screenshot({ fullPage:
|
|
511
|
+
currentBuffer = await page.screenshot({ fullPage: isFullPageDiff }) as Buffer;
|
|
509
512
|
}
|
|
510
513
|
|
|
511
514
|
const { compareScreenshots } = await import('../png-compare');
|
|
@@ -619,6 +622,36 @@ export async function handleMetaCommand(
|
|
|
619
622
|
throw new Error('Usage: browse har start | browse har stop [path]');
|
|
620
623
|
}
|
|
621
624
|
|
|
625
|
+
// ─── Video Recording ─────────────────────────────────
|
|
626
|
+
case 'video': {
|
|
627
|
+
const subcommand = args[0];
|
|
628
|
+
if (!subcommand) throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
|
|
629
|
+
|
|
630
|
+
if (subcommand === 'start') {
|
|
631
|
+
const dir = args[1] || (currentSession
|
|
632
|
+
? `${currentSession.outputDir}`
|
|
633
|
+
: `${LOCAL_DIR}`);
|
|
634
|
+
await bm.startVideoRecording(dir);
|
|
635
|
+
return `Video recording started — output dir: ${dir}`;
|
|
636
|
+
}
|
|
637
|
+
|
|
638
|
+
if (subcommand === 'stop') {
|
|
639
|
+
const result = await bm.stopVideoRecording();
|
|
640
|
+
if (!result) throw new Error('No active video recording. Run "browse video start" first.');
|
|
641
|
+
const duration = ((Date.now() - result.startedAt) / 1000).toFixed(1);
|
|
642
|
+
return `Video saved: ${result.paths.join(', ')} (${duration}s)`;
|
|
643
|
+
}
|
|
644
|
+
|
|
645
|
+
if (subcommand === 'status') {
|
|
646
|
+
const recording = bm.getVideoRecording();
|
|
647
|
+
if (!recording) return 'No active video recording';
|
|
648
|
+
const duration = ((Date.now() - recording.startedAt) / 1000).toFixed(1);
|
|
649
|
+
return `Video recording active — dir: ${recording.dir}, duration: ${duration}s`;
|
|
650
|
+
}
|
|
651
|
+
|
|
652
|
+
throw new Error('Usage: browse video start [dir] | browse video stop | browse video status');
|
|
653
|
+
}
|
|
654
|
+
|
|
622
655
|
// ─── Semantic Locator ──────────────────────────────
|
|
623
656
|
case 'find': {
|
|
624
657
|
const root = bm.getLocatorRoot();
|
package/src/server.ts
CHANGED
|
@@ -129,7 +129,7 @@ const META_COMMANDS = new Set([
|
|
|
129
129
|
'url', 'snapshot', 'snapshot-diff', 'screenshot-diff',
|
|
130
130
|
'sessions', 'session-close',
|
|
131
131
|
'frame', 'state', 'find',
|
|
132
|
-
'auth', 'har', 'inspect',
|
|
132
|
+
'auth', 'har', 'video', 'inspect',
|
|
133
133
|
]);
|
|
134
134
|
|
|
135
135
|
// Probe if a port is free using net.createServer (not Bun.serve which fatally crashes on EADDRINUSE)
|