chrome-agent 0.1.0__py3-none-any.whl

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,5 @@
1
+ """chrome-agent: CLI tool for AI agents to observe and interact with Chrome via CDP."""
2
+
3
+ from importlib.metadata import version
4
+
5
+ __version__ = version("chrome-agent")
@@ -0,0 +1,5 @@
1
+ """Allow running as: python -m chrome_agent"""
2
+
3
+ from chrome_agent.cli import main
4
+
5
+ main()
@@ -0,0 +1,190 @@
1
+ """Browser launcher with optional anti-detection fingerprinting.
2
+
3
+ Launches a Playwright Chromium browser with CDP enabled. Optionally
4
+ applies a fingerprint profile to make the browser appear as a real
5
+ desktop browser.
6
+ """
7
+
8
+ import asyncio
9
+ import json
10
+ import logging
11
+ import os
12
+ import subprocess
13
+ from dataclasses import dataclass
14
+
15
+ from playwright.async_api import Browser, Page, Playwright, async_playwright
16
+
17
+ logger = logging.getLogger(__name__)
18
+
19
+
20
+ @dataclass
21
+ class BrowserFingerprint:
22
+ """Browser fingerprint profile for anti-detection."""
23
+
24
+ user_agent: str
25
+ viewport: dict[str, int]
26
+ locale: str
27
+ timezone: str
28
+ platform: str
29
+ vendor: str
30
+
31
+
32
+ @dataclass
33
+ class BrowserSession:
34
+ """Handle to a launched browser session."""
35
+
36
+ playwright: Playwright
37
+ browser: Browser
38
+ page: Page
39
+
40
+
41
+ async def load_fingerprint(*, path: str) -> BrowserFingerprint:
42
+ """Load a browser fingerprint profile from a JSON file.
43
+
44
+ Expected JSON schema:
45
+ {
46
+ "userAgent": "...",
47
+ "platform": "...",
48
+ "vendor": "...",
49
+ "language": "en-US",
50
+ "timezone": "America/Chicago",
51
+ "viewport": {"width": 1920, "height": 1080}
52
+ }
53
+ """
54
+ with open(path, "r") as f:
55
+ data = json.load(f)
56
+
57
+ return BrowserFingerprint(
58
+ user_agent=data["userAgent"],
59
+ viewport=data["viewport"],
60
+ locale=data["language"],
61
+ timezone=data["timezone"],
62
+ platform=data["platform"],
63
+ vendor=data["vendor"],
64
+ )
65
+
66
+
67
+ async def launch_browser(
68
+ *,
69
+ port: int = 9222,
70
+ fingerprint: BrowserFingerprint | None = None,
71
+ headless: bool = False,
72
+ wm_class: str = "chrome-agent",
73
+ pin_to_desktop: bool = True,
74
+ ) -> BrowserSession:
75
+ """Launch a Chromium browser with CDP enabled.
76
+
77
+ Args:
78
+ port: CDP remote debugging port.
79
+ fingerprint: Optional fingerprint for anti-detection. If None,
80
+ launches a clean browser with no spoofing.
81
+ headless: Run in headless mode.
82
+ wm_class: X11 window class name (for desktop pinning).
83
+ pin_to_desktop: Move browser window to the launching terminal's
84
+ virtual desktop. Default True. Linux/X11 only, requires
85
+ xdotool -- silently skipped on other platforms.
86
+ """
87
+ playwright = await async_playwright().start()
88
+
89
+ launch_args = [
90
+ "--disable-blink-features=AutomationControlled",
91
+ f"--remote-debugging-port={port}",
92
+ f"--class={wm_class}",
93
+ ]
94
+
95
+ browser = await playwright.chromium.launch(
96
+ headless=headless,
97
+ args=launch_args,
98
+ )
99
+
100
+ # Build context options
101
+ context_kwargs = {}
102
+ if fingerprint:
103
+ context_kwargs.update(
104
+ user_agent=fingerprint.user_agent,
105
+ viewport=fingerprint.viewport,
106
+ locale=fingerprint.locale,
107
+ timezone_id=fingerprint.timezone,
108
+ )
109
+
110
+ context = await browser.new_context(**context_kwargs)
111
+
112
+ # Apply anti-detection init script if fingerprinted
113
+ if fingerprint:
114
+ await context.add_init_script(f"""
115
+ Object.defineProperty(navigator, 'webdriver', {{
116
+ get: () => false
117
+ }});
118
+
119
+ Object.defineProperty(navigator, 'platform', {{
120
+ get: () => '{fingerprint.platform}'
121
+ }});
122
+
123
+ Object.defineProperty(navigator, 'vendor', {{
124
+ get: () => '{fingerprint.vendor}'
125
+ }});
126
+
127
+ window.chrome = {{
128
+ runtime: {{}},
129
+ app: {{}}
130
+ }};
131
+ """)
132
+
133
+ page = await context.new_page()
134
+
135
+ session = BrowserSession(
136
+ playwright=playwright,
137
+ browser=browser,
138
+ page=page,
139
+ )
140
+
141
+ if pin_to_desktop:
142
+ await _move_to_launching_desktop(wm_class=wm_class)
143
+
144
+ return session
145
+
146
+
147
+ async def _move_to_launching_desktop(*, wm_class: str) -> None:
148
+ """Move the browser window to the terminal's virtual desktop.
149
+
150
+ Linux/X11 only. Requires xdotool. Silently does nothing if
151
+ xdotool is unavailable or on non-X11 systems.
152
+ """
153
+ try:
154
+ # Determine which desktop our terminal is on
155
+ window_id = os.environ.get("WINDOWID", "")
156
+ if window_id:
157
+ result = subprocess.run(
158
+ ["xdotool", "get_desktop_for_window", window_id],
159
+ capture_output=True, text=True,
160
+ )
161
+ desktop = result.stdout.strip()
162
+ else:
163
+ result = subprocess.run(
164
+ ["xdotool", "get_desktop"],
165
+ capture_output=True, text=True,
166
+ )
167
+ desktop = result.stdout.strip()
168
+
169
+ if not desktop:
170
+ return
171
+
172
+ # Wait for the browser window to appear
173
+ await asyncio.sleep(0.5)
174
+
175
+ # Find our browser windows by the custom WM_CLASS
176
+ result = subprocess.run(
177
+ ["xdotool", "search", "--class", wm_class],
178
+ capture_output=True, text=True,
179
+ )
180
+ for wid in result.stdout.strip().split("\n"):
181
+ if wid.strip():
182
+ subprocess.run(
183
+ ["xdotool", "set_desktop_for_window", wid.strip(), desktop],
184
+ )
185
+
186
+ logger.info("Moved browser window(s) to desktop %s", desktop)
187
+ except FileNotFoundError:
188
+ logger.debug("xdotool not available -- skipping desktop move")
189
+ except Exception as e:
190
+ logger.debug("Could not move browser to desktop: %s", e)
chrome_agent/cli.py ADDED
@@ -0,0 +1,279 @@
1
+ """CLI entry point for chrome-agent.
2
+
3
+ Usage: chrome-agent [--port PORT] <command> [args...]
4
+
5
+ Single entry point with flat command dispatch. The --port flag is global
6
+ and parsed before the command name.
7
+ """
8
+
9
+ import asyncio
10
+ import sys
11
+
12
+ from .commands import (
13
+ cmd_back,
14
+ cmd_check,
15
+ cmd_click,
16
+ cmd_clickxy,
17
+ cmd_close,
18
+ cmd_cookies,
19
+ cmd_element,
20
+ cmd_eval,
21
+ cmd_fill,
22
+ cmd_find,
23
+ cmd_forward,
24
+ cmd_hover,
25
+ cmd_html,
26
+ cmd_navigate,
27
+ cmd_press,
28
+ cmd_reload,
29
+ cmd_screenshot,
30
+ cmd_scroll,
31
+ cmd_select,
32
+ cmd_snapshot,
33
+ cmd_tabs,
34
+ cmd_text,
35
+ cmd_type,
36
+ cmd_uncheck,
37
+ cmd_url,
38
+ cmd_value,
39
+ cmd_viewport,
40
+ cmd_wait,
41
+ )
42
+ from .connection import check_cdp_port, connect, disconnect
43
+ from .errors import ChromeAgentError
44
+
45
+ # Default CDP port
46
+ DEFAULT_PORT = 9222
47
+
48
+ # Command registry: name -> (func, nargs, description)
49
+ # nargs: 0 = no args, 1 = one required arg, 2 = two required args, -1 = optional args
50
+ COMMANDS = {
51
+ # Observe
52
+ "url": (cmd_url, 0, "Print current URL and title"),
53
+ "screenshot": (cmd_screenshot, -1, "Save screenshot [path]"),
54
+ "snapshot": (cmd_snapshot, 0, "Print accessibility tree"),
55
+ "text": (cmd_text, 0, "Print page visible text"),
56
+ "html": (cmd_html, -1, "Print page HTML [selector]"),
57
+ "element": (cmd_element, 1, "Inspect element <selector>"),
58
+ "find": (cmd_find, 1, "Find elements <selector>"),
59
+ "value": (cmd_value, 1, "Get input value <selector>"),
60
+ "eval": (cmd_eval, 1, "Execute JavaScript <code>"),
61
+ "cookies": (cmd_cookies, 0, "Dump cookies"),
62
+ "tabs": (cmd_tabs, 0, "List open pages"),
63
+ "wait": (cmd_wait, 1, "Wait for <selector|ms|load>"),
64
+ # Navigate
65
+ "navigate": (cmd_navigate, 1, "Go to <url>"),
66
+ "back": (cmd_back, 0, "Browser back"),
67
+ "forward": (cmd_forward, 0, "Browser forward"),
68
+ "reload": (cmd_reload, 0, "Reload page"),
69
+ # Interact
70
+ "click": (cmd_click, 1, "Click <selector>"),
71
+ "clickxy": (cmd_clickxy, 2, "Click at <x> <y>"),
72
+ "fill": (cmd_fill, 2, "Fill <selector> <value>"),
73
+ "type": (cmd_type, 2, "Type into <selector> <text>"),
74
+ "press": (cmd_press, 1, "Press key <Enter|Escape|Tab|...>"),
75
+ "select": (cmd_select, 2, "Select option <selector> <value>"),
76
+ "check": (cmd_check, 1, "Check checkbox <selector>"),
77
+ "uncheck": (cmd_uncheck, 1, "Uncheck checkbox <selector>"),
78
+ "hover": (cmd_hover, 1, "Hover over <selector>"),
79
+ "scroll": (cmd_scroll, 1, "Scroll <selector|up|down>"),
80
+ # Meta
81
+ "close": (cmd_close, 0, "Close current page"),
82
+ "viewport": (cmd_viewport, 2, "Resize viewport <width> <height>"),
83
+ }
84
+
85
+ # Commands that don't need a CDP connection
86
+ LOCAL_COMMANDS = {"status", "launch", "help"}
87
+
88
+
89
+ def _print_help() -> None:
90
+ """Print command reference."""
91
+ print("chrome-agent -- CLI for AI agents to observe and interact with Chrome\n")
92
+ print("Usage: chrome-agent [--port PORT] <command> [args...]\n")
93
+ print("Commands:")
94
+ print(f" {'status':12s} Check if browser is running on CDP port")
95
+ print(f" {'launch':12s} Launch a browser with CDP enabled [--fingerprint PATH] [--headless] [--no-pin-desktop]")
96
+ print(f" {'help':12s} Print this help message")
97
+ print()
98
+ for name, (_, _, desc) in COMMANDS.items():
99
+ print(f" {name:12s} {desc}")
100
+
101
+
102
+ def _parse_global_args(argv: list[str]) -> tuple[int, list[str]]:
103
+ """Extract --port from argv, return (port, remaining_args)."""
104
+ port = DEFAULT_PORT
105
+ remaining = []
106
+ i = 0
107
+ while i < len(argv):
108
+ if argv[i] == "--port" and i + 1 < len(argv):
109
+ try:
110
+ port = int(argv[i + 1])
111
+ except ValueError:
112
+ print(f"ERROR: Invalid port: {argv[i + 1]}")
113
+ sys.exit(1)
114
+ i += 2
115
+ else:
116
+ remaining.append(argv[i])
117
+ i += 1
118
+ return port, remaining
119
+
120
+
121
+ async def _run_command(*, port: int, cmd_name: str, args: list[str]) -> None:
122
+ """Connect to browser, run a command, disconnect."""
123
+ func, expected_args, _ = COMMANDS[cmd_name]
124
+
125
+ # Validate argument count
126
+ if expected_args >= 0 and len(args) < expected_args:
127
+ print(f"Command '{cmd_name}' requires {expected_args} argument(s)")
128
+ sys.exit(1)
129
+
130
+ pw, browser, page = await connect(port=port)
131
+ try:
132
+ # Dispatch based on argument pattern
133
+ if cmd_name == "tabs":
134
+ # cmd_tabs needs both page and browser
135
+ await func(page=page, browser=browser)
136
+ elif expected_args == 0:
137
+ await func(page=page)
138
+ elif expected_args == -1:
139
+ # Optional args
140
+ if cmd_name == "screenshot" and args:
141
+ await func(page=page, path=args[0])
142
+ elif cmd_name == "html" and args:
143
+ await func(page=page, selector=args[0])
144
+ else:
145
+ await func(page=page)
146
+ elif expected_args == 1:
147
+ # Single required arg -- map to the right parameter name
148
+ arg = args[0]
149
+ if cmd_name in ("element", "find", "value", "click", "check",
150
+ "uncheck", "hover"):
151
+ await func(page=page, selector=arg)
152
+ elif cmd_name == "eval":
153
+ await func(page=page, js_code=arg)
154
+ elif cmd_name == "navigate":
155
+ await func(page=page, url=arg)
156
+ elif cmd_name == "wait":
157
+ await func(page=page, target=arg)
158
+ elif cmd_name == "press":
159
+ await func(page=page, key=arg)
160
+ elif cmd_name == "scroll":
161
+ await func(page=page, target=arg)
162
+ else:
163
+ await func(page=page)
164
+ elif expected_args == 2:
165
+ # Two required args -- second arg may be multi-word (join remaining)
166
+ arg1 = args[0]
167
+ arg2 = " ".join(args[1:])
168
+ if cmd_name == "clickxy":
169
+ await func(page=page, x=float(arg1), y=float(arg2))
170
+ elif cmd_name == "fill":
171
+ await func(page=page, selector=arg1, value=arg2)
172
+ elif cmd_name == "type":
173
+ await func(page=page, selector=arg1, text=arg2)
174
+ elif cmd_name == "select":
175
+ await func(page=page, selector=arg1, value=arg2)
176
+ elif cmd_name == "viewport":
177
+ await func(page=page, width=int(arg1), height=int(arg2))
178
+ else:
179
+ await func(page=page)
180
+ finally:
181
+ await disconnect(pw=pw)
182
+
183
+
184
+ async def _run_status(*, port: int) -> None:
185
+ """Check if a browser is running on the CDP port."""
186
+ status = check_cdp_port(port=port)
187
+ if status.listening:
188
+ print(f"Browser running on port {port}")
189
+ if status.browser_version:
190
+ print(f" Version: {status.browser_version}")
191
+ if status.page_url:
192
+ print(f" URL: {status.page_url}")
193
+ if status.page_title:
194
+ print(f" Title: {status.page_title}")
195
+ else:
196
+ print(f"No browser running on port {port}")
197
+
198
+
199
+ async def _run_launch(*, port: int, args: list[str]) -> None:
200
+ """Launch a browser with CDP enabled."""
201
+ # Import here to avoid circular imports and loading browser module
202
+ # when it's not needed
203
+ from .browser import launch_browser, load_fingerprint
204
+
205
+ # Parse launch-specific flags
206
+ fingerprint_path = None
207
+ headless = False
208
+ pin_desktop = True # Default: pin browser to launching terminal's desktop
209
+
210
+ i = 0
211
+ while i < len(args):
212
+ if args[i] == "--fingerprint" and i + 1 < len(args):
213
+ fingerprint_path = args[i + 1]
214
+ i += 2
215
+ elif args[i] == "--headless":
216
+ headless = True
217
+ i += 1
218
+ elif args[i] == "--no-pin-desktop":
219
+ pin_desktop = False
220
+ i += 1
221
+ else:
222
+ print(f"Unknown launch option: {args[i]}")
223
+ sys.exit(1)
224
+
225
+ # Load fingerprint if specified
226
+ fingerprint = None
227
+ if fingerprint_path:
228
+ fingerprint = await load_fingerprint(path=fingerprint_path)
229
+
230
+ session = await launch_browser(
231
+ port=port,
232
+ fingerprint=fingerprint,
233
+ headless=headless,
234
+ pin_to_desktop=pin_desktop,
235
+ )
236
+
237
+ print(f"Browser launched on CDP port {port}")
238
+ print("Press Ctrl+C to close.")
239
+
240
+ # Keep alive until interrupted
241
+ try:
242
+ while True:
243
+ await asyncio.sleep(60)
244
+ except (KeyboardInterrupt, asyncio.CancelledError):
245
+ pass
246
+ finally:
247
+ await session.browser.close()
248
+ await session.playwright.stop()
249
+ print("\nBrowser closed.")
250
+
251
+
252
+ def main() -> None:
253
+ """CLI entry point."""
254
+ port, remaining = _parse_global_args(argv=sys.argv[1:])
255
+
256
+ if not remaining or remaining[0] in ("-h", "--help", "help"):
257
+ _print_help()
258
+ sys.exit(0)
259
+
260
+ cmd_name = remaining[0]
261
+ args = remaining[1:]
262
+
263
+ if cmd_name == "status":
264
+ asyncio.run(_run_status(port=port))
265
+ elif cmd_name == "launch":
266
+ asyncio.run(_run_launch(port=port, args=args))
267
+ elif cmd_name in COMMANDS:
268
+ try:
269
+ asyncio.run(_run_command(port=port, cmd_name=cmd_name, args=args))
270
+ except ChromeAgentError as e:
271
+ print(f"ERROR: {e}")
272
+ sys.exit(1)
273
+ except Exception as e:
274
+ print(f"ERROR: {e}")
275
+ sys.exit(1)
276
+ else:
277
+ print(f"Unknown command: {cmd_name}")
278
+ print("Run 'chrome-agent help' to see available commands")
279
+ sys.exit(1)
@@ -0,0 +1,370 @@
1
+ """Browser observation, navigation, and interaction commands.
2
+
3
+ Each command is an async function that accepts a Playwright Page (and
4
+ optionally Browser) as its first argument. Commands print their results
5
+ to stdout and raise exceptions on failure. The CLI layer handles
6
+ connection lifecycle and error presentation.
7
+ """
8
+
9
+ import json
10
+
11
+ from playwright.async_api import Browser, Page
12
+
13
+ from .errors import ElementNotFoundError
14
+
15
+
16
+ # ---------------------------------------------------------------------------
17
+ # Observation commands
18
+ # ---------------------------------------------------------------------------
19
+
20
+
21
+ async def cmd_url(*, page: Page) -> None:
22
+ """Print current URL and page title."""
23
+ print(f"URL: {page.url}")
24
+ print(f"Title: {await page.title()}")
25
+
26
+
27
+ async def cmd_screenshot(*, page: Page, path: str = "/tmp/cdp-screenshot.png") -> None:
28
+ """Save a screenshot of the current page."""
29
+ await page.screenshot(path=path)
30
+ print(f"Screenshot saved: {path}")
31
+
32
+
33
+ async def cmd_snapshot(*, page: Page) -> None:
34
+ """Print the accessibility tree of the current page (ARIA snapshot)."""
35
+ snapshot = await page.locator(":root").aria_snapshot()
36
+ if snapshot:
37
+ print(snapshot)
38
+ else:
39
+ print("(empty accessibility tree)")
40
+
41
+
42
+ async def cmd_text(*, page: Page) -> None:
43
+ """Print visible text content of the page."""
44
+ text = await page.inner_text("body")
45
+ if len(text) > 5000:
46
+ print(text[:5000])
47
+ print(f"\n... (truncated, {len(text)} total chars)")
48
+ else:
49
+ print(text)
50
+
51
+
52
+ async def cmd_html(*, page: Page, selector: str | None = None) -> None:
53
+ """Print page HTML source, or a specific element's outerHTML."""
54
+ if selector:
55
+ el = page.locator(selector).first
56
+ if await el.count() > 0:
57
+ html = await el.evaluate("el => el.outerHTML")
58
+ print(html)
59
+ else:
60
+ raise ElementNotFoundError(selector=selector)
61
+ else:
62
+ html = await page.content()
63
+ if len(html) > 10000:
64
+ print(html[:10000])
65
+ print(f"\n... (truncated, {len(html)} total chars)")
66
+ else:
67
+ print(html)
68
+
69
+
70
+ async def cmd_element(*, page: Page, selector: str) -> None:
71
+ """Print detailed info about a specific element."""
72
+ loc = page.locator(selector).first
73
+
74
+ if await loc.count() == 0:
75
+ raise ElementNotFoundError(selector=selector)
76
+
77
+ info = await loc.evaluate("""el => {
78
+ const cs = getComputedStyle(el);
79
+ const rect = el.getBoundingClientRect();
80
+ const attrs = {};
81
+ for (const a of el.attributes) attrs[a.name] = a.value;
82
+ return {
83
+ tag: el.tagName,
84
+ id: el.id,
85
+ text: el.innerText?.substring(0, 200) || '',
86
+ value: el.value || null,
87
+ type: el.type || null,
88
+ visible: el.offsetParent !== null || cs.display !== 'none',
89
+ display: cs.display,
90
+ visibility: cs.visibility,
91
+ opacity: cs.opacity,
92
+ dimensions: { width: rect.width, height: rect.height },
93
+ position: { x: Math.round(rect.x), y: Math.round(rect.y) },
94
+ inViewport: rect.top >= 0 && rect.left >= 0 &&
95
+ rect.bottom <= window.innerHeight &&
96
+ rect.right <= window.innerWidth,
97
+ disabled: el.disabled || false,
98
+ checked: el.checked ?? null,
99
+ href: el.href || null,
100
+ attributes: attrs,
101
+ };
102
+ }""")
103
+
104
+ print(f"Selector: {selector}")
105
+ print(f"Tag: {info['tag']}")
106
+ if info["type"]:
107
+ print(f"Type: {info['type']}")
108
+ if info["id"]:
109
+ print(f"ID: {info['id']}")
110
+ print(f"Text: {info['text'][:100] if info['text'] else '(empty)'}")
111
+ if info["value"] is not None:
112
+ print(f"Value: {info['value']}")
113
+ print(f"Visible: {info['visible']}")
114
+ print(f"Display: {info['display']}")
115
+ print(f"Visibility: {info['visibility']}")
116
+ print(f"Opacity: {info['opacity']}")
117
+ print(f"Dimensions: {info['dimensions']['width']:.0f} x {info['dimensions']['height']:.0f}")
118
+ print(f"Position: ({info['position']['x']}, {info['position']['y']})")
119
+ print(f"In viewport: {info['inViewport']}")
120
+ if info["disabled"]:
121
+ print(f"Disabled: {info['disabled']}")
122
+ if info["checked"] is not None:
123
+ print(f"Checked: {info['checked']}")
124
+ if info["href"]:
125
+ print(f"Href: {info['href']}")
126
+ if info["attributes"]:
127
+ print(f"Attributes: {json.dumps(info['attributes'], indent=2)}")
128
+
129
+
130
+ async def cmd_find(*, page: Page, selector: str) -> None:
131
+ """Count and summarize elements matching a selector."""
132
+ loc = page.locator(selector)
133
+ count = await loc.count()
134
+
135
+ print(f"Selector: {selector}")
136
+ print(f"Count: {count}")
137
+
138
+ for i in range(min(count, 20)):
139
+ item = loc.nth(i)
140
+ summary = await item.evaluate("""el => ({
141
+ tag: el.tagName,
142
+ text: (el.innerText || '').substring(0, 80),
143
+ visible: el.offsetParent !== null,
144
+ id: el.id || null,
145
+ cls: el.className?.substring?.(0, 60) || null,
146
+ })""")
147
+ vis = "visible" if summary["visible"] else "hidden"
148
+ text = summary["text"].replace("\n", " ").strip()[:60]
149
+ ident = summary["id"] or summary["cls"] or ""
150
+ print(f" [{i}] <{summary['tag']}> {vis} | {ident} | {text}")
151
+
152
+ if count > 20:
153
+ print(f" ... ({count - 20} more)")
154
+
155
+
156
+ async def cmd_value(*, page: Page, selector: str) -> None:
157
+ """Get the current value of an input element."""
158
+ loc = page.locator(selector).first
159
+ if await loc.count() == 0:
160
+ raise ElementNotFoundError(selector=selector)
161
+ val = await loc.input_value()
162
+ print(val)
163
+
164
+
165
+ async def cmd_eval(*, page: Page, js_code: str) -> None:
166
+ """Execute arbitrary JavaScript and print the result."""
167
+ result = await page.evaluate(js_code)
168
+ if isinstance(result, (dict, list)):
169
+ print(json.dumps(result, indent=2))
170
+ else:
171
+ print(result)
172
+
173
+
174
+ async def cmd_cookies(*, page: Page) -> None:
175
+ """Dump all cookies for the current page."""
176
+ context = page.context
177
+ cookies = await context.cookies()
178
+ for c in cookies:
179
+ exp = c.get("expires", -1)
180
+ exp_str = f"expires={exp}" if exp > 0 else "session"
181
+ print(
182
+ f" {c['name']}={c['value'][:40]}"
183
+ f"{'...' if len(c['value']) > 40 else ''}"
184
+ f" ({c['domain']}, {exp_str})"
185
+ )
186
+ if not cookies:
187
+ print("(no cookies)")
188
+
189
+
190
+ async def cmd_tabs(*, page: Page, browser: Browser) -> None:
191
+ """List all open pages/tabs in the browser."""
192
+ for ctx in browser.contexts:
193
+ for i, p in enumerate(ctx.pages):
194
+ marker = " *" if p == page else ""
195
+ print(f" [{i}] {p.url}")
196
+ print(f" {await p.title()}{marker}")
197
+
198
+
199
+ async def cmd_wait(*, page: Page, target: str) -> None:
200
+ """Wait for a selector, milliseconds, or load state."""
201
+ if target.isdigit():
202
+ ms = int(target)
203
+ print(f"Waiting {ms}ms...")
204
+ await page.wait_for_timeout(ms)
205
+ print("Done")
206
+ elif target in ("load", "domcontentloaded", "networkidle"):
207
+ print(f"Waiting for {target}...")
208
+ await page.wait_for_load_state(target)
209
+ print("Done")
210
+ else:
211
+ print(f"Waiting for selector: {target}")
212
+ await page.locator(target).first.wait_for(timeout=30000)
213
+ print("Found")
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # Navigation commands
218
+ # ---------------------------------------------------------------------------
219
+
220
+
221
+ async def cmd_navigate(*, page: Page, url: str) -> None:
222
+ """Navigate to a URL and wait for load."""
223
+ response = await page.goto(url)
224
+ status = response.status if response else "unknown"
225
+ print(f"Navigated to: {page.url}")
226
+ print(f"Status: {status}")
227
+ print(f"Title: {await page.title()}")
228
+
229
+
230
+ async def cmd_back(*, page: Page) -> None:
231
+ """Go back in browser history."""
232
+ await page.go_back()
233
+ print(f"URL: {page.url}")
234
+
235
+
236
+ async def cmd_forward(*, page: Page) -> None:
237
+ """Go forward in browser history."""
238
+ await page.go_forward()
239
+ print(f"URL: {page.url}")
240
+
241
+
242
+ async def cmd_reload(*, page: Page) -> None:
243
+ """Reload the current page."""
244
+ await page.reload()
245
+ print(f"Reloaded: {page.url}")
246
+
247
+
248
+ # ---------------------------------------------------------------------------
249
+ # Interaction commands
250
+ # ---------------------------------------------------------------------------
251
+
252
+
253
+ async def cmd_click(*, page: Page, selector: str) -> None:
254
+ """Click an element. Falls back to JS click if not actionable."""
255
+ loc = page.locator(selector).first
256
+ if await loc.count() == 0:
257
+ raise ElementNotFoundError(selector=selector)
258
+
259
+ try:
260
+ await loc.click(timeout=5000)
261
+ print(f"Clicked: {selector}")
262
+ except Exception:
263
+ # Fallback to JS click for hidden/obscured elements
264
+ try:
265
+ await loc.evaluate("el => el.click()")
266
+ print(f"JS-clicked: {selector} (element was not actionable)")
267
+ except Exception as e:
268
+ print(f"ERROR clicking {selector}: {e}")
269
+
270
+
271
+ async def cmd_clickxy(*, page: Page, x: float, y: float) -> None:
272
+ """Click at page coordinates."""
273
+ await page.mouse.click(x=x, y=y)
274
+ print(f"Clicked at ({x}, {y})")
275
+
276
+
277
+ async def cmd_fill(*, page: Page, selector: str, value: str) -> None:
278
+ """Fill a form field (clears first, dispatches events)."""
279
+ loc = page.locator(selector).first
280
+ if await loc.count() == 0:
281
+ raise ElementNotFoundError(selector=selector)
282
+ await loc.fill(value)
283
+ print(f"Filled {selector} with: {value}")
284
+
285
+
286
+ async def cmd_type(*, page: Page, selector: str, text: str) -> None:
287
+ """Type text character by character into an element."""
288
+ loc = page.locator(selector).first
289
+ if await loc.count() == 0:
290
+ raise ElementNotFoundError(selector=selector)
291
+ await loc.type(text)
292
+ print(f"Typed into {selector}: {text}")
293
+
294
+
295
+ async def cmd_press(*, page: Page, key: str) -> None:
296
+ """Press a keyboard key (Enter, Escape, Tab, etc.)."""
297
+ await page.keyboard.press(key)
298
+ print(f"Pressed: {key}")
299
+
300
+
301
+ async def cmd_select(*, page: Page, selector: str, value: str) -> None:
302
+ """Select an option from a dropdown."""
303
+ loc = page.locator(selector).first
304
+ if await loc.count() == 0:
305
+ raise ElementNotFoundError(selector=selector)
306
+ await loc.select_option(value=value)
307
+ print(f"Selected {value} in {selector}")
308
+
309
+
310
+ async def cmd_check(*, page: Page, selector: str) -> None:
311
+ """Check a checkbox."""
312
+ loc = page.locator(selector).first
313
+ if await loc.count() == 0:
314
+ raise ElementNotFoundError(selector=selector)
315
+ await loc.check()
316
+ print(f"Checked: {selector}")
317
+
318
+
319
+ async def cmd_uncheck(*, page: Page, selector: str) -> None:
320
+ """Uncheck a checkbox."""
321
+ loc = page.locator(selector).first
322
+ if await loc.count() == 0:
323
+ raise ElementNotFoundError(selector=selector)
324
+ await loc.uncheck()
325
+ print(f"Unchecked: {selector}")
326
+
327
+
328
+ async def cmd_hover(*, page: Page, selector: str) -> None:
329
+ """Hover over an element."""
330
+ loc = page.locator(selector).first
331
+ if await loc.count() == 0:
332
+ raise ElementNotFoundError(selector=selector)
333
+ await loc.hover()
334
+ print(f"Hovering: {selector}")
335
+
336
+
337
+ async def cmd_scroll(*, page: Page, target: str) -> None:
338
+ """Scroll to an element, or scroll the page (up/down)."""
339
+ if target == "up":
340
+ await page.mouse.wheel(delta_x=0, delta_y=-500)
341
+ print("Scrolled up")
342
+ elif target == "down":
343
+ await page.mouse.wheel(delta_x=0, delta_y=500)
344
+ print("Scrolled down")
345
+ else:
346
+ loc = page.locator(target).first
347
+ if await loc.count() == 0:
348
+ raise ElementNotFoundError(selector=target)
349
+ await loc.scroll_into_view_if_needed()
350
+ print(f"Scrolled to: {target}")
351
+
352
+
353
+ # ---------------------------------------------------------------------------
354
+ # Meta commands (operate on page/viewport, not elements)
355
+ # ---------------------------------------------------------------------------
356
+
357
+
358
+ async def cmd_close(*, page: Page) -> None:
359
+ """Close the current page."""
360
+ url = page.url
361
+ await page.close()
362
+ print(f"Closed: {url}")
363
+
364
+
365
+ async def cmd_viewport(*, page: Page, width: int, height: int) -> None:
366
+ """Resize the viewport."""
367
+ await page.set_viewport_size({"width": width, "height": height})
368
+ print(f"Viewport set to {width}x{height}")
369
+
370
+
@@ -0,0 +1,109 @@
1
+ """CDP connection management.
2
+
3
+ Handles connecting to and disconnecting from a Chrome browser via the
4
+ Chrome DevTools Protocol. Also provides port status checking using only
5
+ stdlib (no Playwright needed for status).
6
+ """
7
+
8
+ import json
9
+ import os
10
+ import socket
11
+ import urllib.request
12
+ from dataclasses import dataclass
13
+
14
+ from playwright.async_api import Playwright, async_playwright
15
+
16
+ from .errors import BrowserConnectionError, NoPageError
17
+
18
+ # Suppress Node.js deprecation warnings from Playwright internals.
19
+ # This must be set before Playwright spawns its Node.js subprocess.
20
+ os.environ.setdefault("NODE_OPTIONS", "--no-deprecation")
21
+
22
+
23
+ @dataclass
24
+ class PortStatus:
25
+ """Result of checking whether a CDP port is active."""
26
+
27
+ listening: bool
28
+ browser_version: str | None = None
29
+ page_url: str | None = None
30
+ page_title: str | None = None
31
+
32
+
33
+ def check_cdp_port(*, port: int = 9222) -> PortStatus:
34
+ """Check if a browser is listening on the CDP port.
35
+
36
+ Uses stdlib only (socket + urllib) -- no Playwright dependency.
37
+ This is a synchronous function since it only does simple HTTP.
38
+ """
39
+ # Quick socket check first
40
+ sock = socket.socket(socket.AF_INET, socket.SOCK_STREAM)
41
+ sock.settimeout(1)
42
+ try:
43
+ result = sock.connect_ex(("localhost", port))
44
+ if result != 0:
45
+ return PortStatus(listening=False)
46
+ finally:
47
+ sock.close()
48
+
49
+ # Port is open -- try to get browser version
50
+ browser_version = None
51
+ try:
52
+ req = urllib.request.Request(f"http://localhost:{port}/json/version")
53
+ with urllib.request.urlopen(req, timeout=2) as resp:
54
+ data = json.loads(resp.read())
55
+ browser_version = data.get("Browser")
56
+ except Exception:
57
+ pass
58
+
59
+ # Try to get the first page's URL and title
60
+ page_url = None
61
+ page_title = None
62
+ try:
63
+ req = urllib.request.Request(f"http://localhost:{port}/json")
64
+ with urllib.request.urlopen(req, timeout=2) as resp:
65
+ pages = json.loads(resp.read())
66
+ if pages:
67
+ page_url = pages[0].get("url")
68
+ page_title = pages[0].get("title")
69
+ except Exception:
70
+ pass
71
+
72
+ return PortStatus(
73
+ listening=True,
74
+ browser_version=browser_version,
75
+ page_url=page_url,
76
+ page_title=page_title,
77
+ )
78
+
79
+
80
+ async def connect(*, port: int = 9222):
81
+ """Connect to a running browser via CDP.
82
+
83
+ Returns (playwright, browser, page) tuple. The caller is responsible
84
+ for calling disconnect() when done.
85
+
86
+ Raises BrowserConnectionError if no browser is listening.
87
+ Raises NoPageError if the browser has no open pages.
88
+ """
89
+ pw = await async_playwright().start()
90
+
91
+ cdp_url = f"http://localhost:{port}"
92
+ try:
93
+ browser = await pw.chromium.connect_over_cdp(cdp_url)
94
+ except Exception as exc:
95
+ await pw.stop()
96
+ raise BrowserConnectionError(port=port) from exc
97
+
98
+ contexts = browser.contexts
99
+ if not contexts or not contexts[0].pages:
100
+ await pw.stop()
101
+ raise NoPageError()
102
+
103
+ page = contexts[0].pages[0]
104
+ return pw, browser, page
105
+
106
+
107
+ async def disconnect(*, pw: Playwright) -> None:
108
+ """Clean up a Playwright connection."""
109
+ await pw.stop()
chrome_agent/errors.py ADDED
@@ -0,0 +1,45 @@
1
+ """Custom exceptions for chrome-agent.
2
+
3
+ Each exception produces an actionable error message that tells the agent
4
+ what went wrong and what to do about it.
5
+ """
6
+
7
+
8
+ class ChromeAgentError(Exception):
9
+ """Base exception for all chrome-agent errors."""
10
+
11
+
12
+ class BrowserConnectionError(ChromeAgentError):
13
+ """Cannot connect to a browser via CDP.
14
+
15
+ Raised when no browser is listening on the expected CDP port.
16
+ """
17
+
18
+ def __init__(self, *, port: int = 9222):
19
+ super().__init__(
20
+ f"No browser running on port {port}. "
21
+ f"Start one with: chrome-agent launch"
22
+ )
23
+ self.port = port
24
+
25
+
26
+ class NoPageError(ChromeAgentError):
27
+ """Browser is connected but has no open pages.
28
+
29
+ This typically happens when the browser was just launched and hasn't
30
+ navigated anywhere, or when all tabs have been closed.
31
+ """
32
+
33
+ def __init__(self):
34
+ super().__init__("Browser is running but has no open pages.")
35
+
36
+
37
+ class ElementNotFoundError(ChromeAgentError):
38
+ """No element matched the given selector.
39
+
40
+ Raised by commands that require an element to exist (click, fill, etc.).
41
+ """
42
+
43
+ def __init__(self, *, selector: str):
44
+ super().__init__(f"No element found for selector: {selector}")
45
+ self.selector = selector
@@ -0,0 +1,190 @@
1
+ Metadata-Version: 2.4
2
+ Name: chrome-agent
3
+ Version: 0.1.0
4
+ Summary: CLI tool for AI agents to observe and interact with Chrome via CDP
5
+ Keywords: chrome,cdp,devtools,browser,automation,agent
6
+ Author: Corey Gallon
7
+ Author-email: Corey Gallon <366332+captivus@users.noreply.github.com>
8
+ License-Expression: MIT
9
+ Classifier: Development Status :: 3 - Alpha
10
+ Classifier: Environment :: Console
11
+ Classifier: Intended Audience :: Developers
12
+ Classifier: License :: OSI Approved :: MIT License
13
+ Classifier: Programming Language :: Python :: 3
14
+ Classifier: Programming Language :: Python :: 3.11
15
+ Classifier: Programming Language :: Python :: 3.12
16
+ Classifier: Programming Language :: Python :: 3.13
17
+ Classifier: Topic :: Software Development :: Testing
18
+ Classifier: Topic :: Utilities
19
+ Requires-Dist: playwright>=1.50.0
20
+ Requires-Python: >=3.11
21
+ Project-URL: Homepage, https://github.com/captivus/chrome-agent
22
+ Project-URL: Repository, https://github.com/captivus/chrome-agent
23
+ Project-URL: Issues, https://github.com/captivus/chrome-agent/issues
24
+ Description-Content-Type: text/markdown
25
+
26
+ # chrome-agent
27
+
28
+ [![PyPI version](https://img.shields.io/pypi/v/chrome-agent)](https://pypi.org/project/chrome-agent/)
29
+ [![Python versions](https://img.shields.io/pypi/pyversions/chrome-agent)](https://pypi.org/project/chrome-agent/)
30
+ [![License](https://img.shields.io/pypi/l/chrome-agent)](https://github.com/captivus/chrome-agent/blob/main/LICENSE)
31
+
32
+ A CLI tool that gives AI coding agents the ability to observe and interact with Chrome browsers.
33
+
34
+ Built as a replacement for browser MCP tools. Faster, lower token overhead, and supports something MCP tools can't do: multiple agents sharing the same browser instance.
35
+
36
+ ## Why this exists
37
+
38
+ AI coding agents need to see and interact with browsers -- to test their code, debug automation, inspect page state. The standard approach (browser MCP tools) uses a persistent server with protocol negotiation and verbose response formatting. `chrome-agent` takes a different approach: each command is a standalone CLI call that connects to Chrome via the DevTools Protocol, does one thing, and disconnects. No server, no session state, no bloat.
39
+
40
+ This also enables a workflow that MCP tools can't support: one process drives the browser (your automation code) while a separate agent observes the same browser to diagnose issues and improve the code.
41
+
42
+ ## Installation
43
+
44
+ ```bash
45
+ uv tool install chrome-agent
46
+ playwright install chromium
47
+ ```
48
+
49
+ Or add to a project:
50
+
51
+ ```bash
52
+ uv add chrome-agent
53
+ uv run playwright install chromium
54
+ ```
55
+
56
+ ## Two ways to use it
57
+
58
+ ### Drive mode -- you control the browser
59
+
60
+ Launch a browser and interact with it directly. This is the MCP replacement use case.
61
+
62
+ ```bash
63
+ chrome-agent launch &
64
+ chrome-agent navigate "https://example.com"
65
+ chrome-agent text # Read page content
66
+ chrome-agent element "h1" # Inspect an element
67
+ chrome-agent fill "#search" "query" # Fill a form field
68
+ chrome-agent click "#submit" # Click a button
69
+ chrome-agent screenshot /tmp/page.png # Capture the screen
70
+ ```
71
+
72
+ ### Attach mode -- observe a running browser
73
+
74
+ Your automation code launches a browser with `--remote-debugging-port=9222`. You connect to observe what the code is doing, diagnose failures, and figure out what to change.
75
+
76
+ ```bash
77
+ chrome-agent status # Is the browser running?
78
+ chrome-agent url # Where is it?
79
+ chrome-agent element "#submit-btn" # Why can't the code click this?
80
+ chrome-agent eval "document.querySelectorAll('.error').length"
81
+ chrome-agent screenshot # What does it look like?
82
+ ```
83
+
84
+ The feedback loop: **write code -> run it -> observe the browser -> diagnose -> modify code -> repeat.**
85
+
86
+ ## Commands
87
+
88
+ ```
89
+ chrome-agent [--port PORT] <command> [args...]
90
+ ```
91
+
92
+ ### Check browser status
93
+
94
+ ```
95
+ status Check if a browser is running on the CDP port
96
+ launch Launch a browser with CDP enabled
97
+ [--fingerprint PATH] [--headless] [--no-pin-desktop]
98
+ help Print command reference
99
+ ```
100
+
101
+ ### Observe (read-only, always safe)
102
+
103
+ ```
104
+ url Print current URL and page title
105
+ screenshot [path] Save a screenshot (default: /tmp/cdp-screenshot.png)
106
+ snapshot Print the ARIA accessibility tree
107
+ text Print visible text content
108
+ html [selector] Print page HTML or a specific element's HTML
109
+ element <selector> Detailed element inspection (visibility, dimensions,
110
+ attributes, position, disabled state)
111
+ find <selector> Count and list all matching elements
112
+ value <selector> Get an input element's current value
113
+ eval <code> Execute JavaScript and print the result
114
+ cookies List all cookies
115
+ tabs List all open tabs/pages
116
+ wait <target> Wait for a selector, milliseconds, or load state
117
+ ```
118
+
119
+ ### Navigate
120
+
121
+ ```
122
+ navigate <url> Go to a URL
123
+ back Browser back
124
+ forward Browser forward
125
+ reload Reload the page
126
+ ```
127
+
128
+ ### Interact
129
+
130
+ ```
131
+ click <selector> Click an element (JS fallback for hidden elements)
132
+ fill <selector> <val> Fill a form field (clears first)
133
+ type <selector> <txt> Type text character by character
134
+ press <key> Press a keyboard key (Enter, Escape, Tab, etc.)
135
+ select <sel> <value> Select a dropdown option
136
+ check <selector> Check a checkbox
137
+ uncheck <selector> Uncheck a checkbox
138
+ hover <selector> Hover over an element
139
+ scroll <target> Scroll to element, or scroll up/down
140
+ clickxy <x> <y> Click at page coordinates
141
+ close Close the current page
142
+ viewport <w> <h> Resize the viewport
143
+ ```
144
+
145
+ ## For AI agents
146
+
147
+ The primary user of this tool is an AI coding agent, not a human. See [INSTRUCTIONS.md](INSTRUCTIONS.md) for comprehensive agent instructions covering:
148
+
149
+ - Drive mode vs attach mode mental model
150
+ - Safety rules for shared browser access
151
+ - The development feedback loop
152
+ - When to observe vs intervene
153
+ - Command recipes for common tasks
154
+ - Failure modes and recovery
155
+
156
+ Include the contents of `INSTRUCTIONS.md` in your project's `CLAUDE.md` or agent instructions file.
157
+
158
+ ## Browser fingerprinting (optional)
159
+
160
+ For sites that detect automated browsers, launch with a fingerprint profile:
161
+
162
+ ```bash
163
+ chrome-agent launch --fingerprint path/to/fingerprint.json
164
+ ```
165
+
166
+ The fingerprint JSON overrides the browser's user agent, viewport, locale, timezone, and platform to match a real desktop browser:
167
+
168
+ ```json
169
+ {
170
+ "userAgent": "Mozilla/5.0 (X11; Linux x86_64) ...",
171
+ "platform": "Linux x86_64",
172
+ "vendor": "Google Inc.",
173
+ "language": "en-US",
174
+ "timezone": "America/Chicago",
175
+ "viewport": {"width": 1920, "height": 1080}
176
+ }
177
+ ```
178
+
179
+ Without `--fingerprint`, the browser launches with default Chromium settings.
180
+
181
+ ## Requirements
182
+
183
+ - Python >= 3.11
184
+ - Playwright >= 1.50.0
185
+ - Chromium (installed via `playwright install chromium`)
186
+ - Linux with xdotool (optional, for virtual desktop pinning)
187
+
188
+ ## License
189
+
190
+ MIT
@@ -0,0 +1,11 @@
1
+ chrome_agent/__init__.py,sha256=_uI9RMpIIZMyy7AL0Ud2LDClQNzgBUqCJNObUp70wRw,167
2
+ chrome_agent/__main__.py,sha256=xriXccBgzmz1dA1XjCF0j5oFK86n5XWehF55sfWl62g,90
3
+ chrome_agent/browser.py,sha256=Bd3VAt77vkVxe7Yt4ieQx2Ls8v2rLaJVAYtmDtS_L1U,5406
4
+ chrome_agent/cli.py,sha256=liBXlHN__j8JlqEU3bl8oyOTJHYAPupyWa_pv_KXfas,9423
5
+ chrome_agent/commands.py,sha256=oDAV6BP2XghLYCamPkFXfWHnGAGN6b8WF1mjWU-eqsE,12534
6
+ chrome_agent/connection.py,sha256=idzIkcWvMnDBGwchMO2Q8jfzza74y54FiUC2Fwxhnsw,3220
7
+ chrome_agent/errors.py,sha256=mcC64IOsndqHQNQiDvwDaDqrVzKb_nWIW-oA9R8isGk,1280
8
+ chrome_agent-0.1.0.dist-info/WHEEL,sha256=s_zqWxHFEH8b58BCtf46hFCqPaISurdB9R1XJ8za6XI,80
9
+ chrome_agent-0.1.0.dist-info/entry_points.txt,sha256=ZagAP1naQ_eiXWT4YQ0jCZx0b0ExQju_ivHmDWFTqCs,56
10
+ chrome_agent-0.1.0.dist-info/METADATA,sha256=WbqIpJLNx3dNXaPX_fEZCycZKKlZyJUl3pAEWzWt7DE,7024
11
+ chrome_agent-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: uv 0.11.6
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,3 @@
1
+ [console_scripts]
2
+ chrome-agent = chrome_agent.cli:main
3
+