brow-cli 0.1.0__tar.gz

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,27 @@
1
+ __pycache__/
2
+ *.py[cod]
3
+ *$py.class
4
+ *.so
5
+ .Python
6
+ build/
7
+ develop-eggs/
8
+ dist/
9
+ downloads/
10
+ eggs/
11
+ .eggs/
12
+ lib/
13
+ lib64/
14
+ parts/
15
+ sdist/
16
+ var/
17
+ wheels/
18
+ *.egg-info/
19
+ .installed.cfg
20
+ *.egg
21
+ .venv/
22
+ venv/
23
+ ENV/
24
+ env/
25
+ .pytest_cache/
26
+ .coverage
27
+ htmlcov/
@@ -0,0 +1,14 @@
1
+ Metadata-Version: 2.4
2
+ Name: brow-cli
3
+ Version: 0.1.0
4
+ Summary: Standalone Playwright CLI for agent browser automation
5
+ Requires-Python: >=3.11
6
+ Requires-Dist: fastapi>=0.104
7
+ Requires-Dist: httpx>=0.25
8
+ Requires-Dist: playwright>=1.40
9
+ Requires-Dist: typer>=0.9
10
+ Requires-Dist: uvicorn>=0.24
11
+ Provides-Extra: dev
12
+ Requires-Dist: httpx>=0.25; extra == 'dev'
13
+ Requires-Dist: pytest-asyncio>=0.23; extra == 'dev'
14
+ Requires-Dist: pytest>=7.0; extra == 'dev'
@@ -0,0 +1,33 @@
1
+ [build-system]
2
+ requires = ["hatchling"]
3
+ build-backend = "hatchling.build"
4
+
5
+ [project]
6
+ name = "brow-cli"
7
+ version = "0.1.0"
8
+ description = "Standalone Playwright CLI for agent browser automation"
9
+ requires-python = ">=3.11"
10
+ dependencies = [
11
+ "playwright>=1.40",
12
+ "fastapi>=0.104",
13
+ "uvicorn>=0.24",
14
+ "typer>=0.9",
15
+ "httpx>=0.25",
16
+ ]
17
+
18
+ [project.optional-dependencies]
19
+ dev = [
20
+ "pytest>=7.0",
21
+ "pytest-asyncio>=0.23",
22
+ "httpx>=0.25",
23
+ ]
24
+
25
+ [project.scripts]
26
+ brow = "brow.cli:app"
27
+
28
+ [tool.hatch.build.targets.wheel]
29
+ packages = ["src/brow"]
30
+
31
+ [tool.pytest.ini_options]
32
+ asyncio_mode = "auto"
33
+ testpaths = ["tests"]
@@ -0,0 +1 @@
1
+ __version__ = "0.1.0"
@@ -0,0 +1,6 @@
1
+ import sys
2
+ from brow.daemon import run_daemon
3
+
4
+ if __name__ == "__main__":
5
+ port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else None
6
+ run_daemon(port=port)
@@ -0,0 +1,304 @@
1
+ import asyncio
2
+ import subprocess
3
+ import sys
4
+ import time
5
+ from typing import Optional
6
+
7
+ import typer
8
+
9
+ from brow.client import BrowClient
10
+ from brow.config import DAEMON_HOST, DAEMON_PORT
11
+ from brow.daemon import daemon_running, stop_daemon
12
+
13
+ app = typer.Typer(name="brow", no_args_is_help=True)
14
+ daemon_app = typer.Typer(name="daemon", no_args_is_help=True)
15
+ session_app = typer.Typer(name="session", no_args_is_help=True)
16
+ profile_app = typer.Typer(name="profile", no_args_is_help=True)
17
+ state_app = typer.Typer(name="state", no_args_is_help=True)
18
+ page_app = typer.Typer(name="page", no_args_is_help=True)
19
+
20
+ app.add_typer(daemon_app, name="daemon")
21
+ app.add_typer(session_app, name="session")
22
+ app.add_typer(profile_app, name="profile")
23
+ app.add_typer(state_app, name="state")
24
+ app.add_typer(page_app, name="page")
25
+
26
+ session_opt = typer.Option(None, "-s", "--session")
27
+
28
+ def run_async(coro):
29
+ return asyncio.run(coro)
30
+
31
+ def _client():
32
+ return BrowClient()
33
+
34
+ def _daemon_healthy():
35
+ import httpx
36
+ try:
37
+ r = httpx.get(f"http://{DAEMON_HOST}:{DAEMON_PORT}/status", timeout=1.0)
38
+ return r.status_code == 200
39
+ except Exception:
40
+ return False
41
+
42
+ def ensure_daemon():
43
+ if _daemon_healthy():
44
+ return
45
+ subprocess.Popen(
46
+ [sys.executable, "-m", "brow.daemon"],
47
+ stdout=subprocess.DEVNULL,
48
+ stderr=subprocess.DEVNULL,
49
+ start_new_session=True,
50
+ )
51
+ for _ in range(50):
52
+ time.sleep(0.3)
53
+ if _daemon_healthy():
54
+ return
55
+ typer.echo("Failed to start daemon", err=True)
56
+ raise typer.Exit(1)
57
+
58
+ @daemon_app.command("start")
59
+ def daemon_start(port: int = DAEMON_PORT):
60
+ if daemon_running():
61
+ typer.echo("Daemon already running")
62
+ return
63
+ subprocess.Popen(
64
+ [sys.executable, "-m", "brow.daemon", "--port", str(port)],
65
+ stdout=subprocess.DEVNULL,
66
+ stderr=subprocess.DEVNULL,
67
+ start_new_session=True,
68
+ )
69
+ typer.echo(f"Daemon starting on {DAEMON_HOST}:{port}")
70
+
71
+ @daemon_app.command("stop")
72
+ def daemon_stop_cmd():
73
+ if stop_daemon():
74
+ typer.echo("Daemon stopped")
75
+ else:
76
+ typer.echo("Daemon not running")
77
+
78
+ @daemon_app.command("status")
79
+ def daemon_status():
80
+ if not daemon_running():
81
+ typer.echo("Daemon not running")
82
+ return
83
+ c = _client()
84
+ result = run_async(c.get("/status"))
85
+ typer.echo(f"Running — {result['sessions']} active sessions")
86
+
87
+ @session_app.command("new")
88
+ def session_new(profile: str = "default", headed: bool = False):
89
+ ensure_daemon()
90
+ c = _client()
91
+ result = run_async(c.post("/sessions", json={"profile": profile, "headless": not headed}))
92
+ typer.echo(result["id"])
93
+
94
+ @session_app.command("list")
95
+ def session_list():
96
+ ensure_daemon()
97
+ c = _client()
98
+ sessions = run_async(c.get("/sessions"))
99
+ if not sessions:
100
+ typer.echo("No active sessions")
101
+ return
102
+ for s in sessions:
103
+ typer.echo(f"{s['id']}\t{s['profile']}\t{'headed' if not s['headless'] else 'headless'}\t{s['pages']} pages")
104
+
105
+ @session_app.command("delete")
106
+ def session_delete(sid: str):
107
+ ensure_daemon()
108
+ c = _client()
109
+ run_async(c.delete(f"/sessions/{sid}"))
110
+ typer.echo(f"Deleted session {sid}")
111
+
112
+ @app.command("navigate")
113
+ def navigate(url: str, s: Optional[str] = session_opt, timeout: int = 30000):
114
+ ensure_daemon()
115
+ c = _client()
116
+ result = run_async(c.post(f"/browser/{s}/navigate", json={"url": url, "timeout": timeout}))
117
+ typer.echo(f"{result['url']} [{result.get('status', '')}]")
118
+
119
+ @app.command("wait")
120
+ def wait_cmd(selector: Optional[str] = None, load: bool = False, s: Optional[str] = session_opt, timeout: int = 30000):
121
+ ensure_daemon()
122
+ c = _client()
123
+ run_async(c.post(f"/browser/{s}/wait", json={"selector": selector, "load": load, "timeout": timeout}))
124
+ typer.echo("Done")
125
+
126
+ @app.command("snapshot")
127
+ def snapshot_cmd(search: Optional[str] = None, locator: Optional[str] = None, s: Optional[str] = session_opt):
128
+ ensure_daemon()
129
+ c = _client()
130
+ params = {}
131
+ if search:
132
+ params["search"] = search
133
+ if locator:
134
+ params["locator"] = locator
135
+ result = run_async(c.get(f"/browser/{s}/snapshot", params=params))
136
+ typer.echo(result["tree"])
137
+
138
+ @app.command("screenshot")
139
+ def screenshot_cmd(full: bool = False, path: Optional[str] = None, s: Optional[str] = session_opt):
140
+ ensure_daemon()
141
+ c = _client()
142
+ result = run_async(c.post(f"/browser/{s}/screenshot", json={"full": full, "path": path}))
143
+ typer.echo(result["path"])
144
+
145
+ @app.command("html")
146
+ def html_cmd(locator: Optional[str] = None, search: Optional[str] = None, s: Optional[str] = session_opt):
147
+ ensure_daemon()
148
+ c = _client()
149
+ params = {}
150
+ if locator:
151
+ params["locator"] = locator
152
+ if search:
153
+ params["search"] = search
154
+ result = run_async(c.get(f"/browser/{s}/html", params=params))
155
+ typer.echo(result["html"])
156
+
157
+ @app.command("url")
158
+ def url_cmd(s: Optional[str] = session_opt):
159
+ ensure_daemon()
160
+ c = _client()
161
+ result = run_async(c.get(f"/browser/{s}/url"))
162
+ typer.echo(result["url"])
163
+
164
+ @app.command("logs")
165
+ def logs_cmd(search: Optional[str] = None, count: int = 50, s: Optional[str] = session_opt):
166
+ ensure_daemon()
167
+ c = _client()
168
+ params = {"count": count}
169
+ if search:
170
+ params["search"] = search
171
+ result = run_async(c.get(f"/browser/{s}/logs", params=params))
172
+ typer.echo(result["logs"])
173
+
174
+ @app.command("click")
175
+ def click_cmd(selector: str, s: Optional[str] = session_opt, timeout: int = 30000):
176
+ ensure_daemon()
177
+ c = _client()
178
+ run_async(c.post(f"/browser/{s}/click", json={"selector": selector, "timeout": timeout}))
179
+
180
+ @app.command("fill")
181
+ def fill_cmd(selector: str, value: str, s: Optional[str] = session_opt, timeout: int = 30000):
182
+ ensure_daemon()
183
+ c = _client()
184
+ run_async(c.post(f"/browser/{s}/fill", json={"selector": selector, "value": value, "timeout": timeout}))
185
+
186
+ @app.command("type")
187
+ def type_cmd(text: str, s: Optional[str] = session_opt):
188
+ ensure_daemon()
189
+ c = _client()
190
+ run_async(c.post(f"/browser/{s}/type", json={"text": text}))
191
+
192
+ @app.command("key")
193
+ def key_cmd(key: str, s: Optional[str] = session_opt):
194
+ ensure_daemon()
195
+ c = _client()
196
+ run_async(c.post(f"/browser/{s}/key", json={"key": key}))
197
+
198
+ @app.command("hover")
199
+ def hover_cmd(selector: str, s: Optional[str] = session_opt, timeout: int = 30000):
200
+ ensure_daemon()
201
+ c = _client()
202
+ run_async(c.post(f"/browser/{s}/hover", json={"selector": selector, "timeout": timeout}))
203
+
204
+ @app.command("scroll")
205
+ def scroll_cmd(pixels: int = 0, s: Optional[str] = session_opt):
206
+ ensure_daemon()
207
+ c = _client()
208
+ run_async(c.post(f"/browser/{s}/scroll", json={"pixels": pixels}))
209
+
210
+ @app.command("scroll-to")
211
+ def scroll_to_cmd(selector: str, s: Optional[str] = session_opt):
212
+ ensure_daemon()
213
+ c = _client()
214
+ run_async(c.post(f"/browser/{s}/scroll", json={"selector": selector}))
215
+
216
+ @app.command("drag")
217
+ def drag_cmd(source: str, target: str, s: Optional[str] = session_opt):
218
+ ensure_daemon()
219
+ c = _client()
220
+ run_async(c.post(f"/browser/{s}/drag", json={"source": source, "target": target}))
221
+
222
+ @app.command("upload")
223
+ def upload_cmd(selector: str, filepath: str, s: Optional[str] = session_opt):
224
+ ensure_daemon()
225
+ c = _client()
226
+ run_async(c.post(f"/browser/{s}/upload", json={"selector": selector, "filepath": filepath}))
227
+
228
+ @page_app.command("list")
229
+ def page_list(s: Optional[str] = session_opt):
230
+ ensure_daemon()
231
+ c = _client()
232
+ result = run_async(c.get(f"/pages/{s}"))
233
+ for p in result["pages"]:
234
+ typer.echo(f"{p['index']}\t{p['url']}")
235
+
236
+ @page_app.command("new")
237
+ def page_new(url: Optional[str] = None, s: Optional[str] = session_opt):
238
+ ensure_daemon()
239
+ c = _client()
240
+ result = run_async(c.post(f"/pages/{s}/new", json={"url": url}))
241
+ typer.echo(f"Page {result['index']}: {result['url']}")
242
+
243
+ @page_app.command("close")
244
+ def page_close(index: Optional[int] = None, s: Optional[str] = session_opt):
245
+ ensure_daemon()
246
+ c = _client()
247
+ run_async(c.post(f"/pages/{s}/close", params={"index": index} if index is not None else {}))
248
+
249
+ @page_app.command("switch")
250
+ def page_switch(index: int, s: Optional[str] = session_opt):
251
+ ensure_daemon()
252
+ c = _client()
253
+ result = run_async(c.post(f"/pages/{s}/switch", json={"index": index}))
254
+ typer.echo(f"Switched to page {result['active']}: {result['url']}")
255
+
256
+ @profile_app.command("list")
257
+ def profile_list():
258
+ ensure_daemon()
259
+ c = _client()
260
+ result = run_async(c.get("/profiles"))
261
+ for p in result["profiles"]:
262
+ typer.echo(p)
263
+
264
+ @profile_app.command("delete")
265
+ def profile_delete(name: str):
266
+ ensure_daemon()
267
+ c = _client()
268
+ run_async(c.delete(f"/profiles/{name}"))
269
+ typer.echo(f"Deleted profile {name}")
270
+
271
+ @state_app.command("save")
272
+ def state_save(name: str, s: Optional[str] = session_opt):
273
+ ensure_daemon()
274
+ c = _client()
275
+ run_async(c.post("/states/save", json={"name": name, "session_id": s}))
276
+ typer.echo(f"State saved: {name}")
277
+
278
+ @state_app.command("restore")
279
+ def state_restore(name: str, s: Optional[str] = session_opt):
280
+ ensure_daemon()
281
+ c = _client()
282
+ run_async(c.post("/states/restore", json={"name": name, "session_id": s}))
283
+ typer.echo(f"State restored: {name}")
284
+
285
+ @state_app.command("list")
286
+ def state_list():
287
+ ensure_daemon()
288
+ c = _client()
289
+ result = run_async(c.get("/states"))
290
+ for st in result["states"]:
291
+ typer.echo(st)
292
+
293
+ @app.command("eval")
294
+ def eval_cmd(code: str, s: Optional[str] = session_opt, timeout: int = 30000):
295
+ ensure_daemon()
296
+ c = _client()
297
+ result = run_async(c.post(f"/eval/{s}", json={"code": code, "timeout": timeout}))
298
+ if result.get("stdout"):
299
+ typer.echo(result["stdout"], nl=False)
300
+ if result.get("result") is not None:
301
+ typer.echo(result["result"])
302
+
303
+ if __name__ == "__main__":
304
+ app()
@@ -0,0 +1,27 @@
1
+ import httpx
2
+ from brow.config import DAEMON_URL
3
+
4
+ class BrowClient:
5
+ def __init__(self, base_url=None):
6
+ self._client = httpx.AsyncClient(
7
+ base_url=base_url or DAEMON_URL,
8
+ timeout=60.0,
9
+ )
10
+
11
+ async def get(self, path, **kwargs):
12
+ r = await self._client.get(path, **kwargs)
13
+ r.raise_for_status()
14
+ return r.json()
15
+
16
+ async def post(self, path, **kwargs):
17
+ r = await self._client.post(path, **kwargs)
18
+ r.raise_for_status()
19
+ return r.json()
20
+
21
+ async def delete(self, path, **kwargs):
22
+ r = await self._client.delete(path, **kwargs)
23
+ r.raise_for_status()
24
+ return r.json()
25
+
26
+ async def close(self):
27
+ await self._client.aclose()
@@ -0,0 +1,20 @@
1
+ from pathlib import Path
2
+ import os
3
+
4
+ BROW_HOME = Path(os.environ.get("BROW_HOME", Path.home() / ".brow"))
5
+ PROFILES_DIR = BROW_HOME / "profiles"
6
+ STATES_DIR = BROW_HOME / "states"
7
+ SCREENSHOTS_DIR = BROW_HOME / "screenshots"
8
+ PID_FILE = BROW_HOME / "daemon.pid"
9
+ LOG_FILE = BROW_HOME / "daemon.log"
10
+
11
+ DAEMON_HOST = "127.0.0.1"
12
+ DAEMON_PORT = int(os.environ.get("BROW_PORT", "19987"))
13
+ DAEMON_URL = f"http://{DAEMON_HOST}:{DAEMON_PORT}"
14
+
15
+ MAX_SESSIONS = int(os.environ.get("BROW_MAX_SESSIONS", "10"))
16
+ DEFAULT_TIMEOUT = 30000
17
+
18
+ def ensure_dirs():
19
+ for d in [BROW_HOME, PROFILES_DIR, STATES_DIR, SCREENSHOTS_DIR]:
20
+ d.mkdir(parents=True, exist_ok=True)
@@ -0,0 +1,82 @@
1
+ import os
2
+ import signal
3
+ from contextlib import asynccontextmanager
4
+
5
+ import uvicorn
6
+ from fastapi import FastAPI
7
+ from playwright.async_api import async_playwright
8
+
9
+ from brow.config import DAEMON_HOST, DAEMON_PORT, PID_FILE, ensure_dirs
10
+ from brow.session import SessionManager
11
+ from brow.profiles import ProfileManager
12
+
13
+ def create_app():
14
+ manager = SessionManager()
15
+ profiles = ProfileManager()
16
+
17
+ @asynccontextmanager
18
+ async def lifespan(app):
19
+ pw = await async_playwright().start()
20
+ app.state.pw = pw
21
+ app.state.manager = manager
22
+ app.state.profiles = profiles
23
+ yield
24
+ await manager.close_all()
25
+ await pw.stop()
26
+
27
+ app = FastAPI(lifespan=lifespan)
28
+
29
+ from brow.routes.sessions import router as sessions_router
30
+ from brow.routes.browser import router as browser_router
31
+ from brow.routes.pages import router as pages_router
32
+ from brow.routes.profiles import router as profiles_router
33
+ from brow.routes.eval import router as eval_router
34
+
35
+ app.include_router(sessions_router)
36
+ app.include_router(browser_router)
37
+ app.include_router(pages_router)
38
+ app.include_router(profiles_router)
39
+ app.include_router(eval_router)
40
+
41
+ @app.get("/status")
42
+ async def status():
43
+ return {"sessions": len(manager.sessions), "status": "running"}
44
+
45
+ return app
46
+
47
+ def run_daemon(host=None, port=None):
48
+ ensure_dirs()
49
+ host = host or DAEMON_HOST
50
+ port = port or DAEMON_PORT
51
+ PID_FILE.write_text(str(os.getpid()))
52
+ try:
53
+ uvicorn.run(create_app(), host=host, port=port, log_level="warning")
54
+ finally:
55
+ PID_FILE.unlink(missing_ok=True)
56
+
57
+ def stop_daemon():
58
+ if not PID_FILE.exists():
59
+ return False
60
+ pid = int(PID_FILE.read_text().strip())
61
+ try:
62
+ os.kill(pid, signal.SIGTERM)
63
+ except ProcessLookupError:
64
+ pass
65
+ PID_FILE.unlink(missing_ok=True)
66
+ return True
67
+
68
+ def daemon_running():
69
+ if not PID_FILE.exists():
70
+ return False
71
+ pid = int(PID_FILE.read_text().strip())
72
+ try:
73
+ os.kill(pid, 0)
74
+ return True
75
+ except ProcessLookupError:
76
+ PID_FILE.unlink(missing_ok=True)
77
+ return False
78
+
79
+ if __name__ == "__main__":
80
+ import sys
81
+ port = int(sys.argv[sys.argv.index("--port") + 1]) if "--port" in sys.argv else None
82
+ run_daemon(port=port)
@@ -0,0 +1,38 @@
1
+ import json
2
+ import shutil
3
+ import brow.config as cfg
4
+
5
+ class ProfileManager:
6
+ def __init__(self):
7
+ cfg.ensure_dirs()
8
+
9
+ def get_profile_dir(self, name):
10
+ p = cfg.PROFILES_DIR / name
11
+ p.mkdir(parents=True, exist_ok=True)
12
+ return p
13
+
14
+ def list(self):
15
+ if not cfg.PROFILES_DIR.exists():
16
+ return []
17
+ return [d.name for d in cfg.PROFILES_DIR.iterdir() if d.is_dir()]
18
+
19
+ def delete(self, name):
20
+ p = cfg.PROFILES_DIR / name
21
+ if not p.exists():
22
+ raise KeyError(f"Profile '{name}' not found")
23
+ shutil.rmtree(p)
24
+
25
+ def save_state(self, name, state):
26
+ cfg.ensure_dirs()
27
+ (cfg.STATES_DIR / f"{name}.json").write_text(json.dumps(state, indent=2))
28
+
29
+ def load_state(self, name):
30
+ path = cfg.STATES_DIR / f"{name}.json"
31
+ if not path.exists():
32
+ raise FileNotFoundError(f"State '{name}' not found")
33
+ return json.loads(path.read_text())
34
+
35
+ def list_states(self):
36
+ if not cfg.STATES_DIR.exists():
37
+ return []
38
+ return [p.stem for p in cfg.STATES_DIR.glob("*.json")]
File without changes