@ulpi/browse 0.1.0 → 0.2.1

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/README.md CHANGED
@@ -172,15 +172,21 @@ bun install -g @ulpi/browse
172
172
 
173
173
  Requires [Bun](https://bun.sh). Chromium is installed automatically via Playwright.
174
174
 
175
- ### Claude Code Skill (optional)
175
+ ### Claude Code Skill
176
176
 
177
- Install the browse skill into your project so Claude Code uses it automatically:
177
+ Install via [skills.sh](https://skills.sh) (works across Claude Code, Cursor, Cline, Windsurf, and 15+ agents):
178
+
179
+ ```bash
180
+ npx skills add https://github.com/ulpi-io/skills --skill browse
181
+ ```
182
+
183
+ Or install directly into your project:
178
184
 
179
185
  ```bash
180
186
  browse install-skill
181
187
  ```
182
188
 
183
- This copies the skill definition to `.claude/skills/browse/SKILL.md` and adds all browse commands to `.claude/settings.json` permissions — no more approval prompts.
189
+ Both copy the skill definition to `.claude/skills/browse/SKILL.md` and add all browse commands to permissions — no more approval prompts.
184
190
 
185
191
  ## Real-World Example: E-Commerce Flow
186
192
 
package/bin/browse.ts CHANGED
@@ -1,2 +1,11 @@
1
1
  #!/usr/bin/env bun
2
- import '../src/cli';
2
+ import { main } from '../src/cli';
3
+
4
+ if (process.env.__BROWSE_SERVER_MODE === '1') {
5
+ import('../src/server');
6
+ } else {
7
+ main().catch((err) => {
8
+ console.error(`[browse] ${err.message}`);
9
+ process.exit(1);
10
+ });
11
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ulpi/browse",
3
- "version": "0.1.0",
3
+ "version": "0.2.1",
4
4
  "repository": {
5
5
  "type": "git",
6
6
  "url": "https://github.com/ulpi-io/browse"
@@ -37,18 +37,19 @@
37
37
  "publishConfig": {
38
38
  "access": "public"
39
39
  },
40
- "type": "module",
41
- "devDependencies": {
42
- "@types/node": "^25.5.0",
43
- "typescript": "^5.9.3"
44
- },
45
40
  "scripts": {
46
- "build": "bun build --compile src/cli.ts --outfile dist/browse",
41
+ "build": "bun build --compile --external electron --external chromium-bidi src/cli.ts --outfile dist/browse",
42
+ "build:all": "bash scripts/build-all.sh",
47
43
  "dev": "bun run src/cli.ts",
48
44
  "server": "bun run src/server.ts",
49
45
  "test": "bun test",
50
46
  "start": "bun run src/server.ts",
51
47
  "postinstall": "bunx playwright install chromium",
52
48
  "benchmark": "bun run benchmark.ts"
49
+ },
50
+ "type": "module",
51
+ "devDependencies": {
52
+ "@types/node": "^25.5.0",
53
+ "typescript": "^5.9.3"
53
54
  }
54
- }
55
+ }
package/skill/SKILL.md CHANGED
@@ -1,6 +1,6 @@
1
1
  ---
2
2
  name: browse
3
- version: 1.0.0
3
+ version: 2.0.0
4
4
  description: |
5
5
  Fast web browsing for Claude Code via persistent headless Chromium daemon. Navigate to any URL,
6
6
  read page content, click elements, fill forms, run JavaScript, take screenshots,
@@ -53,20 +53,29 @@ If the file is missing or does not contain browse permission rules in `permissio
53
53
  "Bash(browse html:*)", "Bash(browse links:*)", "Bash(browse forms:*)",
54
54
  "Bash(browse accessibility:*)", "Bash(browse snapshot:*)",
55
55
  "Bash(browse snapshot-diff:*)", "Bash(browse click:*)",
56
- "Bash(browse fill:*)", "Bash(browse select:*)", "Bash(browse hover:*)",
57
- "Bash(browse type:*)", "Bash(browse press:*)", "Bash(browse scroll:*)",
58
- "Bash(browse wait:*)", "Bash(browse viewport:*)", "Bash(browse upload:*)",
56
+ "Bash(browse dblclick:*)", "Bash(browse fill:*)", "Bash(browse select:*)",
57
+ "Bash(browse hover:*)", "Bash(browse focus:*)",
58
+ "Bash(browse check:*)", "Bash(browse uncheck:*)",
59
+ "Bash(browse type:*)", "Bash(browse press:*)",
60
+ "Bash(browse keydown:*)", "Bash(browse keyup:*)",
61
+ "Bash(browse scroll:*)", "Bash(browse wait:*)",
62
+ "Bash(browse viewport:*)", "Bash(browse upload:*)",
63
+ "Bash(browse drag:*)", "Bash(browse highlight:*)", "Bash(browse download:*)",
59
64
  "Bash(browse dialog-accept:*)", "Bash(browse dialog-dismiss:*)",
60
65
  "Bash(browse js:*)", "Bash(browse eval:*)", "Bash(browse css:*)",
61
- "Bash(browse attrs:*)", "Bash(browse state:*)", "Bash(browse dialog:*)",
66
+ "Bash(browse attrs:*)", "Bash(browse element-state:*)", "Bash(browse dialog:*)",
62
67
  "Bash(browse console:*)", "Bash(browse network:*)",
63
68
  "Bash(browse cookies:*)", "Bash(browse storage:*)", "Bash(browse perf:*)",
69
+ "Bash(browse value:*)", "Bash(browse count:*)",
64
70
  "Bash(browse devices:*)", "Bash(browse emulate:*)",
65
71
  "Bash(browse screenshot:*)", "Bash(browse pdf:*)",
66
72
  "Bash(browse responsive:*)", "Bash(browse diff:*)",
67
73
  "Bash(browse chain:*)", "Bash(browse tabs:*)", "Bash(browse tab:*)",
68
74
  "Bash(browse newtab:*)", "Bash(browse closetab:*)",
75
+ "Bash(browse frame:*)",
69
76
  "Bash(browse sessions:*)", "Bash(browse session-close:*)",
77
+ "Bash(browse state:*)", "Bash(browse auth:*)", "Bash(browse har:*)",
78
+ "Bash(browse route:*)", "Bash(browse offline:*)",
70
79
  "Bash(browse status:*)", "Bash(browse stop:*)", "Bash(browse restart:*)",
71
80
  "Bash(browse cookie:*)", "Bash(browse header:*)",
72
81
  "Bash(browse useragent:*)"
@@ -80,6 +89,9 @@ If the file is missing or does not contain browse permission rules in `permissio
80
89
  - The browser persists between calls — cookies, tabs, and state carry over.
81
90
  - The server auto-starts on first command. No manual setup needed.
82
91
  - Use `--session <id>` for parallel agent isolation. Each session gets its own tabs, refs, cookies.
92
+ - Use `--json` for structured output (`{success, data, command}`).
93
+ - Use `--content-boundaries` for prompt injection defense.
94
+ - Use `--allowed-domains domain1,domain2` to restrict navigation.
83
95
 
84
96
  ## Quick Reference
85
97
 
@@ -91,7 +103,7 @@ browse goto https://example.com
91
103
  browse text
92
104
 
93
105
  # Take a screenshot (then Read the image)
94
- browse screenshot .browse/page.png
106
+ browse screenshot .browse/sessions/default/screenshot.png
95
107
 
96
108
  # Snapshot: accessibility tree with refs
97
109
  browse snapshot -i
@@ -102,12 +114,25 @@ browse click @e3
102
114
  # Fill by ref
103
115
  browse fill @e4 "test@test.com"
104
116
 
117
+ # Double-click, focus, check/uncheck
118
+ browse dblclick @e3
119
+ browse focus @e5
120
+ browse check @e7
121
+ browse uncheck @e7
122
+
123
+ # Drag and drop
124
+ browse drag @e1 @e2
125
+
105
126
  # Run JavaScript
106
127
  browse js "document.title"
107
128
 
108
129
  # Get all links
109
130
  browse links
110
131
 
132
+ # Get input value / count elements
133
+ browse value "[id=email]"
134
+ browse count ".search-result"
135
+
111
136
  # Click by CSS selector
112
137
  browse click "button.submit"
113
138
 
@@ -116,17 +141,58 @@ browse fill "[id=email]" "test@test.com"
116
141
  browse fill "[id=password]" "abc123"
117
142
  browse click "button[type=submit]"
118
143
 
119
- # Get HTML of an element
120
- browse html "main"
144
+ # Scroll
145
+ browse scroll up
146
+ browse scroll down
147
+ browse scroll "[id=target]"
148
+
149
+ # Wait for navigation or network
150
+ browse wait ".loaded"
151
+ browse wait --url "**/dashboard"
152
+ browse wait --network-idle
121
153
 
122
- # Get computed CSS
123
- browse css "body" "font-family"
154
+ # iframe targeting
155
+ browse frame "[id=my-iframe]"
156
+ browse text # reads from inside the iframe
157
+ browse click @e3 # clicks inside the iframe
158
+ browse frame main # back to main page
124
159
 
125
- # Get element attributes
126
- browse attrs "nav"
160
+ # Highlight an element (visual debugging)
161
+ browse highlight @e5
127
162
 
128
- # Wait for element to appear
129
- browse wait ".loaded"
163
+ # Download a file
164
+ browse download @e3 ./file.pdf
165
+
166
+ # Network mocking
167
+ browse route "**/*.png" block
168
+ browse route "**/api/data" fulfill 200 '{"mock":true}'
169
+ browse route clear
170
+
171
+ # Offline mode
172
+ browse offline on
173
+ browse offline off
174
+
175
+ # JSON output mode
176
+ browse --json goto https://example.com
177
+
178
+ # Security: content boundaries
179
+ browse --content-boundaries text
180
+
181
+ # Security: domain restriction
182
+ browse --allowed-domains example.com,*.cdn.example.com goto https://example.com
183
+
184
+ # State persistence
185
+ browse state save mysite
186
+ browse state load mysite
187
+
188
+ # Auth vault (credentials never visible to LLM)
189
+ browse auth save github https://github.com/login user pass123
190
+ browse auth login github
191
+
192
+ # HAR recording
193
+ browse har start
194
+ browse goto https://example.com
195
+ browse har stop ./recording.har
130
196
 
131
197
  # Device emulation
132
198
  browse emulate iphone
@@ -183,19 +249,36 @@ Refs are invalidated on navigation — run `snapshot` again after `goto`.
183
249
  ### Interaction
184
250
  ```
185
251
  browse click <selector> Click element (CSS selector or @ref)
252
+ browse dblclick <selector> Double-click element
186
253
  browse fill <selector> <value> Fill input field
187
254
  browse select <selector> <val> Select dropdown value
188
255
  browse hover <selector> Hover over element
256
+ browse focus <selector> Focus element
257
+ browse check <selector> Check checkbox
258
+ browse uncheck <selector> Uncheck checkbox
259
+ browse drag <src> <tgt> Drag source to target
189
260
  browse type <text> Type into focused element
190
261
  browse press <key> Press key (Enter, Tab, Escape, etc.)
191
- browse scroll [selector] Scroll element into view, or page bottom
192
- browse wait <selector> Wait for element to appear (max 15s)
262
+ browse keydown <key> Hold key down
263
+ browse keyup <key> Release key
264
+ browse scroll [sel|up|down] Scroll element/viewport/bottom
265
+ browse wait <sel|--url|--network-idle> Wait for element, URL, or network
193
266
  browse viewport <WxH> Set viewport size (e.g. 375x812)
194
267
  browse upload <sel> <files> Upload file(s) to a file input
268
+ browse highlight <selector> Highlight element (visual debugging)
269
+ browse download <sel> [path] Download file triggered by click
195
270
  browse dialog-accept [value] Set dialogs to auto-accept
196
271
  browse dialog-dismiss Set dialogs to auto-dismiss (default)
197
272
  browse emulate <device> Emulate device (iphone, pixel, etc.)
198
273
  browse emulate reset Reset to desktop (1920x1080)
274
+ browse offline [on|off] Toggle offline mode
275
+ ```
276
+
277
+ ### Network
278
+ ```
279
+ browse route <pattern> block Block matching requests
280
+ browse route <pattern> fulfill <s> [b] Mock with status + body
281
+ browse route clear Remove all routes
199
282
  ```
200
283
 
201
284
  ### Inspection
@@ -204,7 +287,9 @@ browse js <expression> Run JS, print result
204
287
  browse eval <js-file> Run JS file against page
205
288
  browse css <selector> <prop> Get computed CSS property
206
289
  browse attrs <selector> Get element attributes as JSON
207
- browse state <selector> Element state (visible/enabled/checked/focused)
290
+ browse element-state <selector> Element state (visible/enabled/checked/focused)
291
+ browse value <selector> Get input field value
292
+ browse count <selector> Count matching elements
208
293
  browse dialog Last dialog info or "(no dialog detected)"
209
294
  browse console [--clear] View/clear console messages
210
295
  browse network [--clear] View/clear network requests
@@ -216,12 +301,18 @@ browse devices [filter] List available device names
216
301
 
217
302
  ### Visual
218
303
  ```
219
- browse screenshot [path] Screenshot (default: .browse/browse-screenshot.png)
304
+ browse screenshot [path] Screenshot (default: .browse/sessions/{id}/screenshot.png)
220
305
  browse screenshot --annotate [path] Screenshot with numbered badges + legend
221
306
  browse pdf [path] Save as PDF
222
307
  browse responsive [prefix] Screenshots at mobile/tablet/desktop
223
308
  ```
224
309
 
310
+ ### Frames (iframe targeting)
311
+ ```
312
+ browse frame <selector> Target an iframe (subsequent commands run inside it)
313
+ browse frame main Return to main page
314
+ ```
315
+
225
316
  ### Compare
226
317
  ```
227
318
  browse diff <url1> <url2> Text diff between two pages
@@ -247,6 +338,26 @@ browse sessions List active sessions
247
338
  browse session-close <id> Close a session
248
339
  ```
249
340
 
341
+ ### State persistence
342
+ ```
343
+ browse state save [name] Save cookies + localStorage (all origins)
344
+ browse state load [name] Restore saved state
345
+ ```
346
+
347
+ ### Auth vault
348
+ ```
349
+ browse auth save <name> <url> <user> <pass> Save credentials (encrypted)
350
+ browse auth login <name> Auto-login using saved credentials
351
+ browse auth list List saved credentials
352
+ browse auth delete <name> Delete credentials
353
+ ```
354
+
355
+ ### HAR recording
356
+ ```
357
+ browse har start Start recording network traffic
358
+ browse har stop [path] Stop and save HAR file
359
+ ```
360
+
250
361
  ### Server management
251
362
  ```
252
363
  browse status Server health, uptime, session count
@@ -254,6 +365,15 @@ browse stop Shutdown server
254
365
  browse restart Kill + restart server
255
366
  ```
256
367
 
368
+ ## CLI Flags
369
+
370
+ | Flag | Description |
371
+ |------|-------------|
372
+ | `--session <id>` | Named session (isolates tabs, refs, cookies) |
373
+ | `--json` | Wrap output as `{success, data, command}` |
374
+ | `--content-boundaries` | Wrap page content in nonce-delimited markers (prompt injection defense) |
375
+ | `--allowed-domains <d,d>` | Block navigation/resources outside allowlist |
376
+
257
377
  ## Speed Rules
258
378
 
259
379
  1. **Navigate once, query many times.** `goto` loads the page; then `text`, `js`, `css`, `screenshot` all run against the loaded page instantly.
@@ -264,6 +384,8 @@ browse restart Kill + restart server
264
384
  6. **Use `chain` for multi-step flows.** Avoids CLI overhead per step.
265
385
  7. **Use `responsive` for layout checks.** One command = 3 viewport screenshots.
266
386
  8. **Use `--session` for parallel work.** Multiple agents can browse simultaneously without interference.
387
+ 9. **Use `value`/`count` instead of `js`.** Purpose-built commands are cleaner than `js "document.querySelector(...).value"`.
388
+ 10. **Use `frame` for iframes.** Don't try to reach into iframes with CSS — use `frame [id=x]` first.
267
389
 
268
390
  ## When to Use What
269
391
 
@@ -272,30 +394,45 @@ browse restart Kill + restart server
272
394
  | Read a page | `goto <url>` then `text` |
273
395
  | Interact with elements | `snapshot -i` then `click @e3` |
274
396
  | Find hidden clickables | `snapshot -i -C` then `click @e15` |
275
- | Check if element exists | `js "!!document.querySelector('.thing')"` |
397
+ | Check if element exists | `count ".thing"` |
398
+ | Get input value | `value "[id=email]"` |
276
399
  | Extract specific data | `js "document.querySelector('.price').textContent"` |
277
- | Visual check | `screenshot .browse/x.png` then Read the image |
400
+ | Visual check | `screenshot .browse/sessions/default/x.png` then Read the image |
278
401
  | Fill and submit form | `snapshot -i` → `fill @e4 "val"` → `click @e5` |
402
+ | Check/uncheck boxes | `check @e7` / `uncheck @e7` |
279
403
  | Check CSS | `css "selector" "property"` or `css @e3 "property"` |
280
404
  | Inspect DOM | `html "selector"` or `attrs @e3` |
281
405
  | Debug console errors | `console` |
282
406
  | Check network requests | `network` |
407
+ | Mock API responses | `route "**/api/*" fulfill 200 '{"data":[]}'` |
408
+ | Block ads/trackers | `route "**/*.doubleclick.net/*" block` |
409
+ | Test offline behavior | `offline on` → test → `offline off` |
410
+ | Interact in iframe | `frame "[id=payment]"` → `fill @e2 "4242..."` → `frame main` |
283
411
  | Check local dev | `goto http://127.0.0.1:3000` |
284
412
  | Compare two pages | `diff <url1> <url2>` |
285
- | Mobile layout check | `responsive .browse/prefix` |
413
+ | Mobile layout check | `responsive .browse/sessions/default/resp` |
286
414
  | Test on mobile device | `emulate iphone` → `goto <url>` → `screenshot` |
415
+ | Save/restore session | `state save mysite` / `state load mysite` |
416
+ | Auto-login | `auth save gh https://github.com/login user pass` → `auth login gh` |
417
+ | Record network | `har start` → browse around → `har stop ./out.har` |
287
418
  | Parallel agents | `--session agent-a <cmd>` / `--session agent-b <cmd>` |
288
419
  | Multi-step flow | `echo '[...]' \| browse chain` |
420
+ | Secure browsing | `--allowed-domains example.com goto https://example.com` |
421
+ | Scroll through results | `scroll down` → `text` → `scroll down` → `text` |
422
+ | Drag and drop | `drag @e1 @e2` |
289
423
 
290
424
  ## Architecture
291
425
 
292
- - Persistent Chromium daemon on localhost (port 9400-9410)
426
+ - Persistent Chromium daemon on localhost (port 9400-10400)
293
427
  - Bearer token auth per session
428
+ - Auto-instance: each parent process (Claude Code) gets its own server
294
429
  - Session multiplexing: multiple agents share one Chromium via isolated BrowserContexts
295
430
  - Project-local state: `.browse/` directory at project root (auto-created, self-gitignored)
296
- - `browse-server.json`server PID, port, auth token
297
- - `browse-console.log` — captured console messages
298
- - `browse-network.log` — captured network requests
299
- - `browse-screenshot.png` — default screenshot location
431
+ - `sessions/{id}/`per-session screenshots, logs, PDFs
432
+ - `states/{name}.json` — saved browser state (cookies + localStorage)
433
+ - `browse-server-{instance}.json` — server PID, port, auth token
300
434
  - Auto-shutdown when all sessions idle past 30 min
301
435
  - Chromium crash → server exits → auto-restarts on next command
436
+ - AI-friendly error messages: Playwright errors rewritten to actionable hints
437
+ - CDP remote connection: `BROWSE_CDP_URL` to connect to existing Chrome
438
+ - Policy enforcement: `browse-policy.json` for allow/deny/confirm rules
@@ -0,0 +1,244 @@
1
+ /**
2
+ * Credential vault — AES-256-GCM encrypted credential storage
3
+ *
4
+ * Encryption key: BROWSE_ENCRYPTION_KEY env var (64-char hex)
5
+ * or auto-generated at .browse/.encryption-key
6
+ *
7
+ * Storage: .browse/auth/<name>.json (mode 0o600)
8
+ * Password never returned in list/get — only hasPassword: true
9
+ */
10
+
11
+ import * as crypto from 'crypto';
12
+ import * as fs from 'fs';
13
+ import * as path from 'path';
14
+ import type { BrowserManager } from './browser-manager';
15
+ import { DEFAULTS } from './constants';
16
+ import { sanitizeName } from './sanitize';
17
+
18
+ interface StoredCredential {
19
+ name: string;
20
+ url: string;
21
+ username: string;
22
+ encrypted: true;
23
+ iv: string; // base64
24
+ authTag: string; // base64
25
+ data: string; // base64 (encrypted password)
26
+ usernameSelector?: string;
27
+ passwordSelector?: string;
28
+ submitSelector?: string;
29
+ createdAt: string;
30
+ updatedAt: string;
31
+ }
32
+
33
+ export interface CredentialInfo {
34
+ name: string;
35
+ url: string;
36
+ username: string;
37
+ hasPassword: boolean;
38
+ createdAt: string;
39
+ }
40
+
41
+ export class AuthVault {
42
+ private authDir: string;
43
+ private encryptionKey: Buffer;
44
+
45
+ constructor(localDir: string) {
46
+ this.authDir = path.join(localDir, 'auth');
47
+ this.encryptionKey = this.resolveKey(localDir);
48
+ }
49
+
50
+ private resolveKey(localDir: string): Buffer {
51
+ // 1. Env var (64-char hex = 32 bytes)
52
+ const envKey = process.env.BROWSE_ENCRYPTION_KEY;
53
+ if (envKey) {
54
+ if (envKey.length !== 64) {
55
+ throw new Error('BROWSE_ENCRYPTION_KEY must be 64 hex characters (32 bytes)');
56
+ }
57
+ return Buffer.from(envKey, 'hex');
58
+ }
59
+
60
+ // 2. Key file
61
+ const keyPath = path.join(localDir, '.encryption-key');
62
+ if (fs.existsSync(keyPath)) {
63
+ const hex = fs.readFileSync(keyPath, 'utf-8').trim();
64
+ return Buffer.from(hex, 'hex');
65
+ }
66
+
67
+ // 3. Auto-generate
68
+ const key = crypto.randomBytes(32);
69
+ fs.writeFileSync(keyPath, key.toString('hex') + '\n', { mode: 0o600 });
70
+ return key;
71
+ }
72
+
73
+ private encrypt(plaintext: string): { ciphertext: string; iv: string; authTag: string } {
74
+ const iv = crypto.randomBytes(12);
75
+ const cipher = crypto.createCipheriv('aes-256-gcm', this.encryptionKey, iv);
76
+ const encrypted = Buffer.concat([cipher.update(plaintext, 'utf-8'), cipher.final()]);
77
+ return {
78
+ ciphertext: encrypted.toString('base64'),
79
+ iv: iv.toString('base64'),
80
+ authTag: cipher.getAuthTag().toString('base64'),
81
+ };
82
+ }
83
+
84
+ private decrypt(ciphertext: string, iv: string, authTag: string): string {
85
+ const decipher = crypto.createDecipheriv(
86
+ 'aes-256-gcm',
87
+ this.encryptionKey,
88
+ Buffer.from(iv, 'base64'),
89
+ );
90
+ decipher.setAuthTag(Buffer.from(authTag, 'base64'));
91
+ const decrypted = Buffer.concat([
92
+ decipher.update(Buffer.from(ciphertext, 'base64')),
93
+ decipher.final(),
94
+ ]);
95
+ return decrypted.toString('utf-8');
96
+ }
97
+
98
+ save(
99
+ name: string,
100
+ url: string,
101
+ username: string,
102
+ password: string,
103
+ selectors?: { username?: string; password?: string; submit?: string },
104
+ ): void {
105
+ fs.mkdirSync(this.authDir, { recursive: true });
106
+
107
+ const { ciphertext, iv, authTag } = this.encrypt(password);
108
+ const now = new Date().toISOString();
109
+
110
+ const credential: StoredCredential = {
111
+ name,
112
+ url,
113
+ username,
114
+ encrypted: true,
115
+ iv,
116
+ authTag,
117
+ data: ciphertext,
118
+ usernameSelector: selectors?.username,
119
+ passwordSelector: selectors?.password,
120
+ submitSelector: selectors?.submit,
121
+ createdAt: now,
122
+ updatedAt: now,
123
+ };
124
+
125
+ const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
126
+ fs.writeFileSync(filePath, JSON.stringify(credential, null, 2), { mode: 0o600 });
127
+ }
128
+
129
+ private load(name: string): StoredCredential {
130
+ const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
131
+ if (!fs.existsSync(filePath)) {
132
+ throw new Error(`Credential "${name}" not found. Run "browse auth list" to see saved credentials.`);
133
+ }
134
+ return JSON.parse(fs.readFileSync(filePath, 'utf-8'));
135
+ }
136
+
137
+ async login(name: string, bm: BrowserManager): Promise<string> {
138
+ const cred = this.load(name);
139
+ const password = this.decrypt(cred.data, cred.iv, cred.authTag);
140
+ const page = bm.getPage();
141
+
142
+ // Navigate to login URL
143
+ await page.goto(cred.url, {
144
+ waitUntil: 'domcontentloaded',
145
+ timeout: DEFAULTS.COMMAND_TIMEOUT_MS,
146
+ });
147
+
148
+ // Resolve selectors: use stored or auto-detect
149
+ const userSel = cred.usernameSelector || await autoDetectSelector(page, 'username');
150
+ const passSel = cred.passwordSelector || await autoDetectSelector(page, 'password');
151
+ const submitSel = cred.submitSelector || await autoDetectSelector(page, 'submit');
152
+
153
+ // Fill credentials
154
+ await page.fill(userSel, cred.username, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
155
+ await page.fill(passSel, password, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
156
+
157
+ // Submit
158
+ await page.click(submitSel, { timeout: DEFAULTS.ACTION_TIMEOUT_MS });
159
+ await page.waitForLoadState('domcontentloaded').catch(() => {});
160
+
161
+ return `Logged in as ${cred.username} at ${page.url()}`;
162
+ }
163
+
164
+ list(): CredentialInfo[] {
165
+ if (!fs.existsSync(this.authDir)) return [];
166
+
167
+ const files = fs.readdirSync(this.authDir).filter(f => f.endsWith('.json'));
168
+ return files.map(f => {
169
+ try {
170
+ const data = JSON.parse(fs.readFileSync(path.join(this.authDir, f), 'utf-8'));
171
+ return {
172
+ name: data.name,
173
+ url: data.url,
174
+ username: data.username,
175
+ hasPassword: true,
176
+ createdAt: data.createdAt,
177
+ };
178
+ } catch {
179
+ return null;
180
+ }
181
+ }).filter(Boolean) as CredentialInfo[];
182
+ }
183
+
184
+ delete(name: string): void {
185
+ const filePath = path.join(this.authDir, `${sanitizeName(name)}.json`);
186
+ if (!fs.existsSync(filePath)) {
187
+ throw new Error(`Credential "${name}" not found.`);
188
+ }
189
+ fs.unlinkSync(filePath);
190
+ }
191
+ }
192
+
193
+ /**
194
+ * Auto-detect login form selectors by common patterns.
195
+ */
196
+ async function autoDetectSelector(page: any, field: 'username' | 'password' | 'submit'): Promise<string> {
197
+ if (field === 'username') {
198
+ const candidates = [
199
+ 'input[type="email"]',
200
+ 'input[name="email"]',
201
+ 'input[name="username"]',
202
+ 'input[name="user"]',
203
+ 'input[name="login"]',
204
+ 'input[autocomplete="username"]',
205
+ 'input[autocomplete="email"]',
206
+ 'input[type="text"]:first-of-type',
207
+ ];
208
+ for (const sel of candidates) {
209
+ const count = await page.locator(sel).count();
210
+ if (count > 0) return sel;
211
+ }
212
+ throw new Error('Could not auto-detect username field. Save with explicit selectors: browse auth save <name> <url> <user> <pass> --user-sel <sel> --pass-sel <sel> --submit-sel <sel>');
213
+ }
214
+
215
+ if (field === 'password') {
216
+ const candidates = [
217
+ 'input[type="password"]',
218
+ 'input[name="password"]',
219
+ 'input[name="pass"]',
220
+ 'input[autocomplete="current-password"]',
221
+ ];
222
+ for (const sel of candidates) {
223
+ const count = await page.locator(sel).count();
224
+ if (count > 0) return sel;
225
+ }
226
+ throw new Error('Could not auto-detect password field.');
227
+ }
228
+
229
+ // submit
230
+ const candidates = [
231
+ 'button[type="submit"]',
232
+ 'input[type="submit"]',
233
+ 'form button',
234
+ 'button:has-text("Log in")',
235
+ 'button:has-text("Sign in")',
236
+ 'button:has-text("Login")',
237
+ 'button:has-text("Submit")',
238
+ ];
239
+ for (const sel of candidates) {
240
+ const count = await page.locator(sel).count();
241
+ if (count > 0) return sel;
242
+ }
243
+ throw new Error('Could not auto-detect submit button.');
244
+ }