@cli4ai/chrome 1.0.9 → 1.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.
Files changed (4) hide show
  1. package/README.md +84 -15
  2. package/cli4ai.json +63 -31
  3. package/package.json +5 -4
  4. package/run.ts +327 -53
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  > Official @cli4ai package • https://cli4ai.com • Install cli4ai: `npm i -g cli4ai`
4
4
 
5
- Chrome profile automation via Puppeteer (connects to a running Chrome with remote debugging enabled).
5
+ Chrome browser automation via Puppeteer. Launches a managed browser instance with persistent profile - no need to quit your existing browser or enable remote debugging.
6
6
 
7
7
  ## Setup
8
8
 
@@ -11,28 +11,97 @@ npm i -g cli4ai
11
11
  cli4ai add -g chrome
12
12
  ```
13
13
 
14
- 1) Quit all running Chrome instances.
14
+ ## Usage
15
15
 
16
- 2) Start Chrome with remote debugging enabled (port `9222`):
16
+ ```bash
17
+ # Navigate to a URL (auto-launches browser if not running)
18
+ cli4ai run chrome navigate https://example.com
19
+
20
+ # Run in headless mode (no visible window)
21
+ cli4ai run chrome --headless navigate https://example.com
17
22
 
18
- - macOS: `open -na "Google Chrome" --args --remote-debugging-port=9222`
19
- - Linux: `google-chrome --remote-debugging-port=9222`
23
+ # Screenshot
24
+ cli4ai run chrome screenshot output.png --full-page
20
25
 
21
- 3) Connect:
26
+ # Get page text
27
+ cli4ai run chrome text
28
+ ```
29
+
30
+ ## Commands
31
+
32
+ ### Browser Management
22
33
 
23
34
  ```bash
24
- cli4ai run chrome connect # default port 9222
25
- # or
26
- cli4ai run chrome connect 9222
35
+ cli4ai run chrome launch # Explicitly launch browser
36
+ cli4ai run chrome --headless launch # Launch in headless mode
37
+ cli4ai run chrome close # Close managed browser
38
+ cli4ai run chrome status # Check if browser is running
39
+ cli4ai run chrome tabs # List open tabs
27
40
  ```
28
41
 
29
- Other browser tools (e.g. `@cli4ai/twitter`, `@cli4ai/linkedin`) reuse this connection.
42
+ ### Navigation
30
43
 
31
- ## Commands
44
+ ```bash
45
+ cli4ai run chrome navigate <url> # Open URL
46
+ cli4ai run chrome navigate <url> --new-tab # Open in new tab
47
+ cli4ai run chrome navigate <url> --wait load # Wait for: load, domcontentloaded, networkidle0, networkidle2
48
+ ```
49
+
50
+ ### Interaction
51
+
52
+ ```bash
53
+ cli4ai run chrome click <selector> # Click element
54
+ cli4ai run chrome type <selector> "text" # Type into input
55
+ cli4ai run chrome type <selector> "text" --clear # Clear first, then type
56
+ cli4ai run chrome wait <selector> # Wait for element
57
+ cli4ai run chrome wait <selector> --timeout 5000 # With timeout (ms)
58
+ ```
59
+
60
+ ### Content Extraction
32
61
 
33
62
  ```bash
34
- cli4ai run chrome navigate <url> [--new-tab]
35
- cli4ai run chrome eval "<js>"
36
- cli4ai run chrome screenshot [file] [--full-page]
37
- cli4ai run chrome cookies [domain]
63
+ cli4ai run chrome html # Get full page HTML
64
+ cli4ai run chrome html <selector> # Get element HTML
65
+ cli4ai run chrome text # Get page text content
66
+ cli4ai run chrome text <selector> # Get element text
67
+ cli4ai run chrome cookies # Get all cookies
68
+ cli4ai run chrome cookies example.com # Filter by domain
69
+ cli4ai run chrome eval "document.title" # Run JavaScript
38
70
  ```
71
+
72
+ ### Screenshots & PDF
73
+
74
+ ```bash
75
+ cli4ai run chrome screenshot # Save screenshot.png
76
+ cli4ai run chrome screenshot output.png # Custom filename
77
+ cli4ai run chrome screenshot --full-page # Full page capture
78
+ cli4ai run chrome screenshot --selector "#hero" # Element screenshot
79
+ cli4ai run chrome pdf # Save page.pdf (headless)
80
+ cli4ai run chrome pdf output.pdf # Custom filename
81
+ ```
82
+
83
+ ## Headless Mode
84
+
85
+ Add `--headless` before any command to run without a visible browser window:
86
+
87
+ ```bash
88
+ cli4ai run chrome --headless navigate https://example.com
89
+ cli4ai run chrome --headless screenshot
90
+ cli4ai run chrome --headless text
91
+ ```
92
+
93
+ Headless mode is useful for:
94
+ - Automated scraping
95
+ - CI/CD pipelines
96
+ - Background tasks
97
+
98
+ ## Profile Persistence
99
+
100
+ Browser data (cookies, localStorage, logins) persists in `~/.cli4ai/chrome/profile/`. This means:
101
+ - You stay logged into websites between sessions
102
+ - Browser extensions are preserved
103
+ - History and settings persist
104
+
105
+ ## Related
106
+
107
+ Other browser-dependent tools like `@cli4ai/twitter` and `@cli4ai/linkedin` can use the managed browser.
package/cli4ai.json CHANGED
@@ -1,63 +1,95 @@
1
1
  {
2
2
  "name": "chrome",
3
- "version": "1.0.9",
4
- "description": "Chrome browser automation (foundation for browser tools)",
3
+ "version": "1.1.0",
4
+ "description": "Chrome browser automation with managed browser instance",
5
5
  "author": "cliforai",
6
- "license": "MIT",
6
+ "license": "BUSL-1.1",
7
7
  "entry": "run.ts",
8
8
  "runtime": "node",
9
9
  "keywords": [
10
10
  "chrome",
11
11
  "browser",
12
12
  "puppeteer",
13
- "automation"
13
+ "automation",
14
+ "headless"
14
15
  ],
15
16
  "commands": {
16
- "connect": {
17
- "description": "Connect to Chrome",
17
+ "launch": {
18
+ "description": "Launch managed browser",
19
+ "args": []
20
+ },
21
+ "close": {
22
+ "description": "Close managed browser",
23
+ "args": []
24
+ },
25
+ "status": {
26
+ "description": "Check browser status",
27
+ "args": []
28
+ },
29
+ "navigate": {
30
+ "description": "Open URL in browser",
18
31
  "args": [
19
- {
20
- "name": "port",
21
- "description": "Default: 9222",
22
- "required": false
23
- }
32
+ { "name": "url", "required": true }
24
33
  ]
25
34
  },
26
- "navigate": {
27
- "description": "Open URL",
35
+ "click": {
36
+ "description": "Click an element",
28
37
  "args": [
29
- {
30
- "name": "url",
31
- "required": true
32
- }
38
+ { "name": "selector", "required": true }
39
+ ]
40
+ },
41
+ "type": {
42
+ "description": "Type text into an input",
43
+ "args": [
44
+ { "name": "selector", "required": true },
45
+ { "name": "text", "required": true }
33
46
  ]
34
47
  },
35
48
  "eval": {
36
- "description": "Run JavaScript",
49
+ "description": "Run JavaScript in page context",
37
50
  "args": [
38
- {
39
- "name": "script",
40
- "required": true
41
- }
51
+ { "name": "script", "required": true }
42
52
  ]
43
53
  },
44
54
  "screenshot": {
45
- "description": "Capture viewport",
55
+ "description": "Capture screenshot",
56
+ "args": [
57
+ { "name": "output", "required": false }
58
+ ]
59
+ },
60
+ "pdf": {
61
+ "description": "Save page as PDF",
46
62
  "args": [
47
- {
48
- "name": "output",
49
- "required": false
50
- }
63
+ { "name": "output", "required": false }
51
64
  ]
52
65
  },
53
66
  "cookies": {
54
67
  "description": "Get cookies",
55
68
  "args": [
56
- {
57
- "name": "domain",
58
- "required": false
59
- }
69
+ { "name": "domain", "required": false }
60
70
  ]
71
+ },
72
+ "html": {
73
+ "description": "Get page HTML",
74
+ "args": [
75
+ { "name": "selector", "required": false }
76
+ ]
77
+ },
78
+ "text": {
79
+ "description": "Get text content",
80
+ "args": [
81
+ { "name": "selector", "required": false }
82
+ ]
83
+ },
84
+ "wait": {
85
+ "description": "Wait for element",
86
+ "args": [
87
+ { "name": "selector", "required": true }
88
+ ]
89
+ },
90
+ "tabs": {
91
+ "description": "List open tabs",
92
+ "args": []
61
93
  }
62
94
  },
63
95
  "dependencies": {
package/package.json CHANGED
@@ -1,9 +1,9 @@
1
1
  {
2
2
  "name": "@cli4ai/chrome",
3
- "version": "1.0.9",
4
- "description": "Chrome browser automation (foundation for browser tools)",
3
+ "version": "1.1.0",
4
+ "description": "Chrome browser automation with managed browser instance",
5
5
  "author": "cliforai",
6
- "license": "MIT",
6
+ "license": "BUSL-1.1",
7
7
  "main": "run.ts",
8
8
  "bin": {
9
9
  "chrome": "./run.ts"
@@ -16,7 +16,8 @@
16
16
  "chrome",
17
17
  "browser",
18
18
  "puppeteer",
19
- "automation"
19
+ "automation",
20
+ "headless"
20
21
  ],
21
22
  "repository": {
22
23
  "type": "git",
package/run.ts CHANGED
@@ -1,93 +1,367 @@
1
1
  #!/usr/bin/env npx tsx
2
- import puppeteer from 'puppeteer';
3
- import { readFileSync, writeFileSync } from 'fs';
2
+ import puppeteer, { Browser, Page } from 'puppeteer';
3
+ import { readFileSync, writeFileSync, existsSync, unlinkSync, mkdirSync } from 'fs';
4
4
  import { join, resolve } from 'path';
5
+ import { homedir } from 'os';
5
6
  import { cli, log, output, outputError, withErrorHandling } from '@cli4ai/lib/cli.ts';
6
7
 
7
- const WS_FILE = join(new URL('.', import.meta.url).pathname, '.ws-endpoint');
8
+ // Persistent storage paths
9
+ const CLI4AI_DIR = join(homedir(), '.cli4ai');
10
+ const CHROME_DIR = join(CLI4AI_DIR, 'chrome');
11
+ const PROFILE_DIR = join(CHROME_DIR, 'profile');
12
+ const WS_FILE = join(CHROME_DIR, 'ws-endpoint');
13
+ const PID_FILE = join(CHROME_DIR, 'pid');
8
14
 
9
- async function getPage(newTab = false) {
10
- let ws: string;
15
+ // Ensure directories exist
16
+ function ensureDirs() {
17
+ if (!existsSync(CLI4AI_DIR)) mkdirSync(CLI4AI_DIR, { recursive: true });
18
+ if (!existsSync(CHROME_DIR)) mkdirSync(CHROME_DIR, { recursive: true });
19
+ }
20
+
21
+ // Check if browser process is still running
22
+ function isBrowserRunning(): boolean {
23
+ if (!existsSync(PID_FILE)) return false;
24
+ const pid = parseInt(readFileSync(PID_FILE, 'utf-8').trim(), 10);
11
25
  try {
12
- ws = readFileSync(WS_FILE, 'utf-8');
26
+ process.kill(pid, 0); // Check if process exists
27
+ return true;
13
28
  } catch {
14
- outputError('NOT_FOUND', 'Not connected to Chrome', {
15
- hint: 'Run: chrome connect'
16
- });
29
+ // Process not running, clean up stale files
30
+ cleanup();
31
+ return false;
32
+ }
33
+ }
34
+
35
+ // Clean up state files
36
+ function cleanup() {
37
+ if (existsSync(WS_FILE)) unlinkSync(WS_FILE);
38
+ if (existsSync(PID_FILE)) unlinkSync(PID_FILE);
39
+ }
40
+
41
+ // Launch a new browser instance
42
+ async function launchBrowser(headless: boolean): Promise<Browser> {
43
+ ensureDirs();
44
+
45
+ const browser = await puppeteer.launch({
46
+ headless: headless ? 'shell' : false,
47
+ userDataDir: PROFILE_DIR,
48
+ args: [
49
+ '--no-first-run',
50
+ '--no-default-browser-check',
51
+ '--disable-infobars',
52
+ ],
53
+ defaultViewport: null, // Use full window size
54
+ });
55
+
56
+ // Save connection info
57
+ const wsEndpoint = browser.wsEndpoint();
58
+ writeFileSync(WS_FILE, wsEndpoint);
59
+
60
+ // Get browser process PID
61
+ const browserProcess = browser.process();
62
+ if (browserProcess?.pid) {
63
+ writeFileSync(PID_FILE, String(browserProcess.pid));
17
64
  }
18
65
 
19
- const browser = await puppeteer.connect({ browserWSEndpoint: ws });
66
+ return browser;
67
+ }
68
+
69
+ // Get or create browser instance
70
+ async function getBrowser(headless: boolean): Promise<Browser> {
71
+ // Try to connect to existing browser
72
+ if (isBrowserRunning() && existsSync(WS_FILE)) {
73
+ try {
74
+ const ws = readFileSync(WS_FILE, 'utf-8').trim();
75
+ return await puppeteer.connect({ browserWSEndpoint: ws });
76
+ } catch {
77
+ // Connection failed, clean up and launch new
78
+ cleanup();
79
+ }
80
+ }
81
+
82
+ // Launch new browser
83
+ return launchBrowser(headless);
84
+ }
85
+
86
+ // Get a page from the browser
87
+ async function getPage(browser: Browser, newTab = false): Promise<Page> {
88
+ if (newTab) {
89
+ return browser.newPage();
90
+ }
20
91
  const pages = await browser.pages();
21
- const page = newTab ? await browser.newPage() : (pages[pages.length - 1] || await browser.newPage());
22
- return { page, browser };
92
+ return pages[pages.length - 1] || browser.newPage();
23
93
  }
24
94
 
25
- const program = cli('chrome', '1.0.0', 'Chrome browser automation');
95
+ // Helper to run commands with browser
96
+ async function withBrowser<T>(
97
+ fn: (browser: Browser, page: Page) => Promise<T>,
98
+ options: { headless?: boolean; newTab?: boolean } = {}
99
+ ): Promise<T> {
100
+ const browser = await getBrowser(options.headless ?? false);
101
+ const page = await getPage(browser, options.newTab);
102
+ try {
103
+ return await fn(browser, page);
104
+ } finally {
105
+ browser.disconnect(); // Disconnect but keep browser running
106
+ }
107
+ }
108
+
109
+ const program = cli('chrome', '1.0.9', 'Chrome browser automation');
110
+
111
+ // Global headless option
112
+ program.option('--headless', 'Run in headless mode');
26
113
 
27
114
  program
28
- .command('connect [port]')
29
- .description('Connect to Chrome (default: 9222)')
30
- .action(withErrorHandling(async (port = '9222') => {
31
- const browser = await puppeteer.connect({ browserURL: `http://127.0.0.1:${port}` });
32
- const wsEndpoint = browser.wsEndpoint();
33
- writeFileSync(WS_FILE, wsEndpoint);
34
- log(`Connected on port ${port}`);
35
- output({ connected: true, port, wsEndpoint });
115
+ .command('launch')
116
+ .description('Launch managed browser')
117
+ .action(withErrorHandling(async () => {
118
+ const headless = program.opts().headless ?? false;
119
+
120
+ if (isBrowserRunning()) {
121
+ log('Browser already running');
122
+ const ws = readFileSync(WS_FILE, 'utf-8').trim();
123
+ output({ running: true, wsEndpoint: ws, profileDir: PROFILE_DIR });
124
+ return;
125
+ }
126
+
127
+ const browser = await launchBrowser(headless);
128
+ log(`Browser launched ${headless ? '(headless)' : ''}`);
129
+ output({
130
+ launched: true,
131
+ headless,
132
+ wsEndpoint: browser.wsEndpoint(),
133
+ profileDir: PROFILE_DIR
134
+ });
36
135
  browser.disconnect();
37
136
  }));
38
137
 
138
+ program
139
+ .command('close')
140
+ .description('Close managed browser')
141
+ .action(withErrorHandling(async () => {
142
+ if (!isBrowserRunning()) {
143
+ log('No browser running');
144
+ output({ closed: false, reason: 'not_running' });
145
+ return;
146
+ }
147
+
148
+ try {
149
+ const ws = readFileSync(WS_FILE, 'utf-8').trim();
150
+ const browser = await puppeteer.connect({ browserWSEndpoint: ws });
151
+ await browser.close();
152
+ cleanup();
153
+ log('Browser closed');
154
+ output({ closed: true });
155
+ } catch (err) {
156
+ cleanup();
157
+ output({ closed: true, note: 'force_cleaned' });
158
+ }
159
+ }));
160
+
161
+ program
162
+ .command('status')
163
+ .description('Check browser status')
164
+ .action(withErrorHandling(async () => {
165
+ const running = isBrowserRunning();
166
+ output({
167
+ running,
168
+ profileDir: PROFILE_DIR,
169
+ wsEndpoint: running && existsSync(WS_FILE) ? readFileSync(WS_FILE, 'utf-8').trim() : null,
170
+ });
171
+ }));
172
+
39
173
  program
40
174
  .command('navigate <url>')
41
- .description('Open URL')
175
+ .description('Open URL in browser')
42
176
  .option('--new-tab', 'Open in new tab')
43
- .action(withErrorHandling(async (url: string, options: { newTab?: boolean }) => {
44
- const { page, browser } = await getPage(options.newTab);
45
- await page.goto(url, { waitUntil: 'networkidle2' });
46
- output({ title: await page.title(), url: page.url() });
47
- if (options.newTab) await page.close();
48
- browser.disconnect();
177
+ .option('--wait <event>', 'Wait for: load, domcontentloaded, networkidle0, networkidle2', 'networkidle2')
178
+ .action(withErrorHandling(async (url: string, options: { newTab?: boolean; wait?: string }) => {
179
+ const headless = program.opts().headless ?? false;
180
+ const waitUntil = options.wait as 'load' | 'domcontentloaded' | 'networkidle0' | 'networkidle2';
181
+
182
+ await withBrowser(async (browser, page) => {
183
+ await page.goto(url, { waitUntil });
184
+ const title = await page.title();
185
+ log(`Navigated to: ${title}`);
186
+ output({ title, url: page.url() });
187
+ }, { headless, newTab: options.newTab });
188
+ }));
189
+
190
+ program
191
+ .command('click <selector>')
192
+ .description('Click an element')
193
+ .action(withErrorHandling(async (selector: string) => {
194
+ const headless = program.opts().headless ?? false;
195
+
196
+ await withBrowser(async (browser, page) => {
197
+ await page.waitForSelector(selector, { timeout: 5000 });
198
+ await page.click(selector);
199
+ log(`Clicked: ${selector}`);
200
+ output({ clicked: true, selector });
201
+ }, { headless });
202
+ }));
203
+
204
+ program
205
+ .command('type <selector> <text>')
206
+ .description('Type text into an input')
207
+ .option('--clear', 'Clear field first')
208
+ .action(withErrorHandling(async (selector: string, text: string, options: { clear?: boolean }) => {
209
+ const headless = program.opts().headless ?? false;
210
+
211
+ await withBrowser(async (browser, page) => {
212
+ await page.waitForSelector(selector, { timeout: 5000 });
213
+ if (options.clear) {
214
+ await page.click(selector, { clickCount: 3 });
215
+ }
216
+ await page.type(selector, text);
217
+ log(`Typed into: ${selector}`);
218
+ output({ typed: true, selector, length: text.length });
219
+ }, { headless });
49
220
  }));
50
221
 
51
222
  program
52
223
  .command('eval <script>')
53
- .description('Run JavaScript (WARNING: executes arbitrary code in browser context)')
224
+ .description('Run JavaScript in page context')
54
225
  .action(withErrorHandling(async (script: string) => {
55
- // SECURITY WARNING: This command executes arbitrary JavaScript in the browser.
56
- // Only use with trusted input. Malicious scripts can access cookies, localStorage,
57
- // and perform actions as the logged-in user.
58
- log('⚠️ WARNING: Executing arbitrary JavaScript in browser context');
59
- const { page, browser } = await getPage();
60
- const result = await page.evaluate((code: string) => eval(code), script);
61
- if (result !== undefined) output(result);
62
- browser.disconnect();
226
+ const headless = program.opts().headless ?? false;
227
+ log('Executing JavaScript in browser context');
228
+
229
+ await withBrowser(async (browser, page) => {
230
+ const result = await page.evaluate((code: string) => {
231
+ return eval(code);
232
+ }, script);
233
+ if (result !== undefined) output(result);
234
+ }, { headless });
63
235
  }));
64
236
 
65
237
  program
66
238
  .command('screenshot [output]')
67
- .description('Capture viewport')
239
+ .description('Capture screenshot')
68
240
  .option('--full-page', 'Capture full page')
69
- .action(withErrorHandling(async (outputFile = 'screenshot.png', options: { fullPage?: boolean }) => {
70
- const { page, browser } = await getPage();
241
+ .option('--selector <sel>', 'Screenshot specific element')
242
+ .action(withErrorHandling(async (outputFile = 'screenshot.png', options: { fullPage?: boolean; selector?: string }) => {
243
+ const headless = program.opts().headless ?? false;
71
244
  const outputPath = resolve(outputFile);
72
- await page.screenshot({ path: outputPath, fullPage: options.fullPage });
73
- log(`Saved: ${outputPath}`);
74
- output({ path: outputPath });
75
- browser.disconnect();
245
+
246
+ await withBrowser(async (browser, page) => {
247
+ if (options.selector) {
248
+ const element = await page.$(options.selector);
249
+ if (!element) {
250
+ outputError('NOT_FOUND', `Element not found: ${options.selector}`);
251
+ }
252
+ await element!.screenshot({ path: outputPath });
253
+ } else {
254
+ await page.screenshot({ path: outputPath, fullPage: options.fullPage });
255
+ }
256
+ log(`Saved: ${outputPath}`);
257
+ output({ path: outputPath });
258
+ }, { headless });
259
+ }));
260
+
261
+ program
262
+ .command('pdf [output]')
263
+ .description('Save page as PDF (headless only)')
264
+ .action(withErrorHandling(async (outputFile = 'page.pdf') => {
265
+ const outputPath = resolve(outputFile);
266
+
267
+ // PDF requires headless mode
268
+ await withBrowser(async (browser, page) => {
269
+ await page.pdf({ path: outputPath, format: 'A4' });
270
+ log(`Saved: ${outputPath}`);
271
+ output({ path: outputPath });
272
+ }, { headless: true });
76
273
  }));
77
274
 
78
275
  program
79
276
  .command('cookies [domain]')
80
277
  .description('Get cookies')
81
278
  .action(withErrorHandling(async (domain?: string) => {
82
- const { page, browser } = await getPage();
83
- const client = await page.createCDPSession();
84
- const { cookies } = await client.send('Network.getAllCookies');
85
- const filtered = domain && domain !== '--all'
86
- ? cookies.filter((c: { domain: string }) => c.domain.includes(domain))
87
- : cookies;
88
- output(filtered);
89
- log(`\n${filtered.length} cookies`);
90
- browser.disconnect();
279
+ const headless = program.opts().headless ?? false;
280
+
281
+ await withBrowser(async (browser, page) => {
282
+ const client = await page.createCDPSession();
283
+ const { cookies } = await client.send('Network.getAllCookies');
284
+ const filtered = domain
285
+ ? cookies.filter((c: { domain: string }) => c.domain.includes(domain))
286
+ : cookies;
287
+ output(filtered);
288
+ log(`${filtered.length} cookies`);
289
+ }, { headless });
290
+ }));
291
+
292
+ program
293
+ .command('html [selector]')
294
+ .description('Get page HTML or element HTML')
295
+ .action(withErrorHandling(async (selector?: string) => {
296
+ const headless = program.opts().headless ?? false;
297
+
298
+ await withBrowser(async (browser, page) => {
299
+ let html: string;
300
+ if (selector) {
301
+ const element = await page.$(selector);
302
+ if (!element) {
303
+ outputError('NOT_FOUND', `Element not found: ${selector}`);
304
+ }
305
+ html = await page.evaluate(el => el!.outerHTML, element);
306
+ } else {
307
+ html = await page.content();
308
+ }
309
+ output({ html, length: html.length });
310
+ }, { headless });
311
+ }));
312
+
313
+ program
314
+ .command('text [selector]')
315
+ .description('Get text content')
316
+ .action(withErrorHandling(async (selector?: string) => {
317
+ const headless = program.opts().headless ?? false;
318
+
319
+ await withBrowser(async (browser, page) => {
320
+ let text: string;
321
+ if (selector) {
322
+ const element = await page.$(selector);
323
+ if (!element) {
324
+ outputError('NOT_FOUND', `Element not found: ${selector}`);
325
+ }
326
+ text = await page.evaluate(el => el!.textContent || '', element);
327
+ } else {
328
+ text = await page.evaluate(() => document.body.innerText);
329
+ }
330
+ output({ text: text.trim(), length: text.trim().length });
331
+ }, { headless });
332
+ }));
333
+
334
+ program
335
+ .command('wait <selector>')
336
+ .description('Wait for element to appear')
337
+ .option('--timeout <ms>', 'Timeout in milliseconds', '30000')
338
+ .action(withErrorHandling(async (selector: string, options: { timeout: string }) => {
339
+ const headless = program.opts().headless ?? false;
340
+ const timeout = parseInt(options.timeout, 10);
341
+
342
+ await withBrowser(async (browser, page) => {
343
+ await page.waitForSelector(selector, { timeout });
344
+ log(`Found: ${selector}`);
345
+ output({ found: true, selector });
346
+ }, { headless });
347
+ }));
348
+
349
+ program
350
+ .command('tabs')
351
+ .description('List open tabs')
352
+ .action(withErrorHandling(async () => {
353
+ const headless = program.opts().headless ?? false;
354
+
355
+ await withBrowser(async (browser, page) => {
356
+ const pages = await browser.pages();
357
+ const tabs = await Promise.all(pages.map(async (p, i) => ({
358
+ index: i,
359
+ url: p.url(),
360
+ title: await p.title(),
361
+ })));
362
+ output(tabs);
363
+ log(`${tabs.length} tabs`);
364
+ }, { headless });
91
365
  }));
92
366
 
93
367
  program.parse();