camoufox-cli 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.
File without changes
@@ -0,0 +1,32 @@
1
+ """Entry point: python -m camoufox_cli"""
2
+
3
+ import argparse
4
+ import sys
5
+
6
+ from .server import DaemonServer
7
+
8
+
9
+ def main():
10
+ parser = argparse.ArgumentParser(description="camoufox-cli daemon server")
11
+ parser.add_argument("--session", default="default", help="Session name")
12
+ parser.add_argument("--headless", action="store_true", default=True, help="Run headless (default)")
13
+ parser.add_argument("--headed", action="store_true", help="Show browser window")
14
+ parser.add_argument("--timeout", type=int, default=1800, help="Idle timeout in seconds")
15
+ parser.add_argument("--persistent", default=None, help="Path for persistent browser profile")
16
+ args = parser.parse_args()
17
+
18
+ headless = not args.headed
19
+
20
+ server = DaemonServer(
21
+ session=args.session,
22
+ headless=headless,
23
+ timeout=args.timeout,
24
+ persistent=args.persistent,
25
+ )
26
+
27
+ print(f"[camoufox-cli] Starting daemon session={args.session} headless={headless}", file=sys.stderr)
28
+ server.start()
29
+
30
+
31
+ if __name__ == "__main__":
32
+ main()
@@ -0,0 +1,99 @@
1
+ """Browser manager: launches and manages Camoufox instance."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from camoufox.sync_api import Camoufox
6
+ from playwright.sync_api import BrowserContext, Page
7
+
8
+ from .refs import RefRegistry
9
+
10
+
11
+ class BrowserManager:
12
+ def __init__(self, persistent: str | None = None):
13
+ self._camoufox: Camoufox | None = None
14
+ self._context: BrowserContext | None = None
15
+ self._page: Page | None = None
16
+ self.refs = RefRegistry()
17
+ self._headless: bool = True
18
+ self._persistent = persistent
19
+
20
+ def launch(self, headless: bool = True) -> None:
21
+ if self._camoufox is not None:
22
+ return
23
+ self._headless = headless
24
+
25
+ kwargs: dict = {"headless": headless}
26
+ if self._persistent:
27
+ kwargs["persistent_context"] = True
28
+ kwargs["user_data_dir"] = self._persistent
29
+
30
+ self._camoufox = Camoufox(**kwargs)
31
+ result = self._camoufox.__enter__()
32
+
33
+ if self._persistent:
34
+ # persistent_context returns BrowserContext directly
35
+ self._context = result
36
+ pages = self._context.pages
37
+ self._page = pages[0] if pages else self._context.new_page()
38
+ else:
39
+ # Normal mode: result is Browser, new_page() creates default context + page
40
+ self._page = result.new_page()
41
+ self._context = self._page.context
42
+
43
+ def get_page(self) -> Page:
44
+ if self._page is None:
45
+ raise RuntimeError("Browser not launched. Send 'open' command first.")
46
+ return self._page
47
+
48
+ def get_context(self) -> BrowserContext:
49
+ if self._context is None:
50
+ raise RuntimeError("Browser not launched. Send 'open' command first.")
51
+ return self._context
52
+
53
+ def get_tabs(self) -> list[dict]:
54
+ ctx = self.get_context()
55
+ tabs = []
56
+ for i, p in enumerate(ctx.pages):
57
+ tabs.append({
58
+ "index": i,
59
+ "url": p.url,
60
+ "title": p.title(),
61
+ "active": p is self._page,
62
+ })
63
+ return tabs
64
+
65
+ def switch_to_tab(self, index: int) -> Page:
66
+ ctx = self.get_context()
67
+ pages = ctx.pages
68
+ if index < 0 or index >= len(pages):
69
+ raise IndexError(f"Tab index {index} out of range (0-{len(pages) - 1})")
70
+ self._page = pages[index]
71
+ self._page.bring_to_front()
72
+ return self._page
73
+
74
+ def close_current_tab(self) -> None:
75
+ ctx = self.get_context()
76
+ pages = ctx.pages
77
+ if len(pages) <= 1:
78
+ raise RuntimeError("Cannot close the last tab. Use 'close' to shut down the browser.")
79
+ current = self._page
80
+ # Switch to another tab before closing
81
+ idx = pages.index(current)
82
+ new_idx = idx - 1 if idx > 0 else 1
83
+ self._page = pages[new_idx]
84
+ self._page.bring_to_front()
85
+ current.close()
86
+
87
+ def close(self) -> None:
88
+ if self._camoufox is not None:
89
+ try:
90
+ self._camoufox.__exit__(None, None, None)
91
+ except Exception:
92
+ pass
93
+ self._camoufox = None
94
+ self._context = None
95
+ self._page = None
96
+
97
+ @property
98
+ def is_running(self) -> bool:
99
+ return self._camoufox is not None
camoufox_cli/cli.py ADDED
@@ -0,0 +1,385 @@
1
+ """CLI client: parses args, starts daemon if needed, sends command via Unix socket."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import os
7
+ import socket
8
+ import subprocess
9
+ import sys
10
+ import time
11
+
12
+
13
+ SOCKET_PREFIX = "/tmp/camoufox-cli-"
14
+
15
+
16
+ def get_socket_path(session: str) -> str:
17
+ return f"{SOCKET_PREFIX}{session}.sock"
18
+
19
+
20
+ def send_command(sock_path: str, command: dict) -> dict:
21
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
22
+ s.connect(sock_path)
23
+ s.sendall(json.dumps(command).encode() + b"\n")
24
+ s.shutdown(socket.SHUT_WR)
25
+ data = b""
26
+ while True:
27
+ chunk = s.recv(4096)
28
+ if not chunk:
29
+ break
30
+ data += chunk
31
+ s.close()
32
+ return json.loads(data.decode())
33
+
34
+
35
+ def spawn_daemon(session: str, headed: bool, timeout: int, persistent: str | None) -> None:
36
+ cmd = [sys.executable, "-m", "camoufox_cli", "--session", session, "--timeout", str(timeout)]
37
+ if headed:
38
+ cmd.append("--headed")
39
+ if persistent:
40
+ cmd.extend(["--persistent", persistent])
41
+
42
+ subprocess.Popen(
43
+ cmd,
44
+ stdin=subprocess.DEVNULL,
45
+ stdout=subprocess.DEVNULL,
46
+ stderr=subprocess.DEVNULL,
47
+ start_new_session=True,
48
+ )
49
+
50
+ sock_path = get_socket_path(session)
51
+ for _ in range(50):
52
+ if os.path.exists(sock_path):
53
+ return
54
+ time.sleep(0.1)
55
+
56
+ print("Error: Daemon did not start within 5 seconds", file=sys.stderr)
57
+ sys.exit(1)
58
+
59
+
60
+ def ensure_daemon(session: str, headed: bool, timeout: int, persistent: str | None) -> None:
61
+ sock_path = get_socket_path(session)
62
+ if not os.path.exists(sock_path):
63
+ spawn_daemon(session, headed, timeout, persistent)
64
+
65
+
66
+ def list_sessions() -> list[str]:
67
+ sessions = []
68
+ try:
69
+ for name in os.listdir("/tmp"):
70
+ if name.startswith("camoufox-cli-") and name.endswith(".sock"):
71
+ sessions.append(name[len("camoufox-cli-"):-len(".sock")])
72
+ except OSError:
73
+ pass
74
+ sessions.sort()
75
+ return sessions
76
+
77
+
78
+ def parse_args(args: list[str]) -> tuple[dict, dict]:
79
+ """Parse CLI args into (flags, command). Returns (flags_dict, command_json)."""
80
+ flags = {"session": "default", "headed": False, "timeout": 1800, "json": False, "persistent": None}
81
+ rest = []
82
+
83
+ i = 0
84
+ while i < len(args):
85
+ if args[i] == "--session":
86
+ i += 1
87
+ if i >= len(args):
88
+ print("Error: --session requires a value", file=sys.stderr)
89
+ sys.exit(1)
90
+ flags["session"] = args[i]
91
+ elif args[i] == "--headed":
92
+ flags["headed"] = True
93
+ elif args[i] == "--timeout":
94
+ i += 1
95
+ if i >= len(args):
96
+ print("Error: --timeout requires a value", file=sys.stderr)
97
+ sys.exit(1)
98
+ flags["timeout"] = int(args[i])
99
+ elif args[i] == "--json":
100
+ flags["json"] = True
101
+ elif args[i] == "--persistent":
102
+ i += 1
103
+ if i >= len(args):
104
+ print("Error: --persistent requires a value", file=sys.stderr)
105
+ sys.exit(1)
106
+ flags["persistent"] = args[i]
107
+ else:
108
+ rest.append(args[i])
109
+ i += 1
110
+
111
+ if not rest:
112
+ print(USAGE, file=sys.stderr)
113
+ sys.exit(1)
114
+
115
+ action = rest[0]
116
+ cmd = build_command(action, rest)
117
+ return flags, cmd
118
+
119
+
120
+ def build_command(action: str, rest: list[str]) -> dict:
121
+ """Build JSON command from action and remaining args."""
122
+ match action:
123
+ # Navigation
124
+ case "open":
125
+ url = _require(rest, 1, "Usage: camoufox-cli open <url>")
126
+ return {"id": "r1", "action": "open", "params": {"url": url}}
127
+ case "back":
128
+ return {"id": "r1", "action": "back", "params": {}}
129
+ case "forward":
130
+ return {"id": "r1", "action": "forward", "params": {}}
131
+ case "reload":
132
+ return {"id": "r1", "action": "reload", "params": {}}
133
+ case "url":
134
+ return {"id": "r1", "action": "url", "params": {}}
135
+ case "title":
136
+ return {"id": "r1", "action": "title", "params": {}}
137
+ case "close":
138
+ all_flag = "--all" in rest
139
+ return {"id": "r1", "action": "close", "params": {"all": all_flag}}
140
+
141
+ # Snapshot
142
+ case "snapshot":
143
+ interactive = "-i" in rest
144
+ selector = None
145
+ if "-s" in rest:
146
+ idx = rest.index("-s")
147
+ selector = _require(rest, idx + 1, "Usage: camoufox-cli snapshot -s <selector>")
148
+ params = {"interactive": interactive}
149
+ if selector:
150
+ params["selector"] = selector
151
+ return {"id": "r1", "action": "snapshot", "params": params}
152
+
153
+ # Interaction
154
+ case "click":
155
+ ref = _require(rest, 1, "Usage: camoufox-cli click @e1")
156
+ return {"id": "r1", "action": "click", "params": {"ref": ref}}
157
+ case "fill":
158
+ ref = _require(rest, 1, "Usage: camoufox-cli fill @e1 \"text\"")
159
+ text = _require(rest, 2, "Usage: camoufox-cli fill @e1 \"text\"")
160
+ return {"id": "r1", "action": "fill", "params": {"ref": ref, "text": text}}
161
+ case "type":
162
+ ref = _require(rest, 1, "Usage: camoufox-cli type @e1 \"text\"")
163
+ text = _require(rest, 2, "Usage: camoufox-cli type @e1 \"text\"")
164
+ return {"id": "r1", "action": "type", "params": {"ref": ref, "text": text}}
165
+ case "select":
166
+ ref = _require(rest, 1, "Usage: camoufox-cli select @e1 \"option\"")
167
+ value = _require(rest, 2, "Usage: camoufox-cli select @e1 \"option\"")
168
+ return {"id": "r1", "action": "select", "params": {"ref": ref, "value": value}}
169
+ case "check":
170
+ ref = _require(rest, 1, "Usage: camoufox-cli check @e1")
171
+ return {"id": "r1", "action": "check", "params": {"ref": ref}}
172
+ case "hover":
173
+ ref = _require(rest, 1, "Usage: camoufox-cli hover @e1")
174
+ return {"id": "r1", "action": "hover", "params": {"ref": ref}}
175
+ case "press":
176
+ key = _require(rest, 1, "Usage: camoufox-cli press Enter")
177
+ return {"id": "r1", "action": "press", "params": {"key": key}}
178
+
179
+ # Data extraction
180
+ case "text":
181
+ target = _require(rest, 1, "Usage: camoufox-cli text @e1 | camoufox-cli text body")
182
+ return {"id": "r1", "action": "text", "params": {"target": target}}
183
+ case "eval":
184
+ expr = _require(rest, 1, "Usage: camoufox-cli eval \"document.title\"")
185
+ return {"id": "r1", "action": "eval", "params": {"expression": expr}}
186
+ case "screenshot":
187
+ params = {}
188
+ for arg in rest[1:]:
189
+ if arg == "--full":
190
+ params["full_page"] = True
191
+ else:
192
+ params["path"] = arg
193
+ return {"id": "r1", "action": "screenshot", "params": params}
194
+ case "pdf":
195
+ path = _require(rest, 1, "Usage: camoufox-cli pdf output.pdf")
196
+ return {"id": "r1", "action": "pdf", "params": {"path": path}}
197
+
198
+ # Scroll & Wait
199
+ case "scroll":
200
+ direction = _require(rest, 1, "Usage: camoufox-cli scroll down [px]")
201
+ amount = int(rest[2]) if len(rest) > 2 else 500
202
+ return {"id": "r1", "action": "scroll", "params": {"direction": direction, "amount": amount}}
203
+ case "wait":
204
+ target = _require(rest, 1, "Usage: camoufox-cli wait @e1 | camoufox-cli wait 2000 | camoufox-cli wait --url \"pattern\"")
205
+ if target == "--url":
206
+ pattern = _require(rest, 2, "Usage: camoufox-cli wait --url \"*/dashboard\"")
207
+ return {"id": "r1", "action": "wait", "params": {"url": pattern}}
208
+ elif target.startswith("@"):
209
+ return {"id": "r1", "action": "wait", "params": {"ref": target}}
210
+ elif target[0].isdigit():
211
+ return {"id": "r1", "action": "wait", "params": {"ms": int(target)}}
212
+ else:
213
+ return {"id": "r1", "action": "wait", "params": {"selector": target}}
214
+
215
+ # Tab management
216
+ case "tabs":
217
+ return {"id": "r1", "action": "tabs", "params": {}}
218
+ case "switch":
219
+ index = _require(rest, 1, "Usage: camoufox-cli switch <tab-index>")
220
+ return {"id": "r1", "action": "switch", "params": {"index": int(index)}}
221
+ case "close-tab":
222
+ return {"id": "r1", "action": "close-tab", "params": {}}
223
+
224
+ # Session & Cookies
225
+ case "sessions":
226
+ return {"id": "r1", "action": "sessions", "params": {}}
227
+ case "cookies":
228
+ if len(rest) > 1 and rest[1] == "import":
229
+ path = _require(rest, 2, "Usage: camoufox-cli cookies import file.json")
230
+ return {"id": "r1", "action": "cookies", "params": {"op": "import", "path": path}}
231
+ elif len(rest) > 1 and rest[1] == "export":
232
+ path = _require(rest, 2, "Usage: camoufox-cli cookies export file.json")
233
+ return {"id": "r1", "action": "cookies", "params": {"op": "export", "path": path}}
234
+ else:
235
+ return {"id": "r1", "action": "cookies", "params": {"op": "list"}}
236
+
237
+ case _:
238
+ print(f"Unknown command: {action}\n{USAGE}", file=sys.stderr)
239
+ sys.exit(1)
240
+
241
+
242
+ def _require(args: list[str], idx: int, usage: str) -> str:
243
+ if idx >= len(args):
244
+ print(usage, file=sys.stderr)
245
+ sys.exit(1)
246
+ return args[idx]
247
+
248
+
249
+ def print_response(response: dict, json_mode: bool) -> None:
250
+ if json_mode:
251
+ print(json.dumps(response, indent=2, ensure_ascii=False))
252
+ return
253
+
254
+ if not response.get("success"):
255
+ print(f"Error: {response.get('error', 'Unknown error')}", file=sys.stderr)
256
+ sys.exit(1)
257
+
258
+ data = response.get("data")
259
+ if not data:
260
+ return
261
+
262
+ if "snapshot" in data:
263
+ print(data["snapshot"])
264
+ elif "text" in data:
265
+ print(data["text"])
266
+ elif "result" in data:
267
+ v = data["result"]
268
+ print("null" if v is None else json.dumps(v, ensure_ascii=False) if not isinstance(v, str) else v)
269
+ elif data.get("closed"):
270
+ pass # silent
271
+ elif "url" in data:
272
+ if "title" in data:
273
+ print(data["title"])
274
+ print(data["url"])
275
+ elif "title" in data:
276
+ print(data["title"])
277
+ elif not data:
278
+ pass # silent success
279
+ else:
280
+ print(json.dumps(data, indent=2, ensure_ascii=False))
281
+
282
+
283
+ def main():
284
+ args = sys.argv[1:]
285
+ flags, command = parse_args(args)
286
+
287
+ action = command.get("action", "")
288
+
289
+ # Client-side: sessions
290
+ if action == "sessions":
291
+ sessions = list_sessions()
292
+ if flags["json"]:
293
+ print(json.dumps(sessions, indent=2))
294
+ elif not sessions:
295
+ print("No active sessions.")
296
+ else:
297
+ for s in sessions:
298
+ print(s)
299
+ return
300
+
301
+ # Client-side: close --all
302
+ if action == "close" and command.get("params", {}).get("all"):
303
+ sessions = list_sessions()
304
+ if not sessions:
305
+ print("No active sessions.")
306
+ return
307
+ close_cmd = {"id": "r1", "action": "close", "params": {}}
308
+ for session in sessions:
309
+ sock_path = get_socket_path(session)
310
+ try:
311
+ send_command(sock_path, close_cmd)
312
+ except Exception as e:
313
+ print(f"Failed to close session {session}: {e}", file=sys.stderr)
314
+ return
315
+
316
+ # Ensure daemon is running
317
+ ensure_daemon(flags["session"], flags["headed"], flags["timeout"], flags["persistent"])
318
+
319
+ sock_path = get_socket_path(flags["session"])
320
+
321
+ # Send command with retry
322
+ last_err = ""
323
+ for attempt in range(5):
324
+ try:
325
+ response = send_command(sock_path, command)
326
+ print_response(response, flags["json"])
327
+ return
328
+ except Exception as e:
329
+ last_err = str(e)
330
+ if attempt < 4:
331
+ time.sleep(0.2 * (attempt + 1))
332
+
333
+ print(f"Error: Failed to connect to daemon after 5 attempts: {last_err}", file=sys.stderr)
334
+ sys.exit(1)
335
+
336
+
337
+ USAGE = """\
338
+ Usage: camoufox-cli [flags] <command> [args]
339
+
340
+ Navigation:
341
+ open <url> Navigate to URL
342
+ back Go back
343
+ forward Go forward
344
+ reload Reload page
345
+ url Print current URL
346
+ title Print page title
347
+ close [--all] Close browser and daemon (--all: all sessions)
348
+
349
+ Snapshot:
350
+ snapshot [-i] [-s sel] Aria tree (-i interactive, -s scoped)
351
+
352
+ Interaction:
353
+ click @ref Click element
354
+ fill @ref "text" Clear + type into input
355
+ type @ref "text" Type without clearing
356
+ select @ref "option" Select dropdown option
357
+ check @ref Toggle checkbox
358
+ hover @ref Hover over element
359
+ press <key> Press key (e.g. Enter, Control+a)
360
+
361
+ Data:
362
+ text @ref|selector Get text content
363
+ eval "js expression" Execute JavaScript
364
+ screenshot [--full] [f] Screenshot to file or stdout
365
+ pdf <file> Save page as PDF
366
+
367
+ Scroll & Wait:
368
+ scroll <dir> [px] Scroll up/down (default 500px)
369
+ wait <ms|@ref|--url p> Wait for time/element/URL
370
+
371
+ Tabs:
372
+ tabs List open tabs
373
+ switch <index> Switch to tab
374
+ close-tab Close current tab
375
+
376
+ Session:
377
+ sessions List active sessions
378
+ cookies [import|export] Manage cookies
379
+
380
+ Flags:
381
+ --session <name> Session name (default: "default")
382
+ --headed Show browser window
383
+ --timeout <secs> Daemon idle timeout (default: 1800)
384
+ --json Output as JSON
385
+ --persistent <path> Use persistent browser profile"""
@@ -0,0 +1,343 @@
1
+ """Command implementations for the daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import base64
6
+ import json
7
+
8
+ from .browser import BrowserManager
9
+ from .protocol import ok_response, error_response
10
+
11
+
12
+ def execute(manager: BrowserManager, command: dict) -> dict:
13
+ """Dispatch and execute a command, return a response dict."""
14
+ cmd_id = command.get("id", "?")
15
+ action = command.get("action", "")
16
+ params = command.get("params", {})
17
+
18
+ try:
19
+ handler = _HANDLERS.get(action)
20
+ if handler is None:
21
+ return error_response(cmd_id, f"Unknown action: {action}")
22
+ return handler(manager, cmd_id, params)
23
+ except Exception as e:
24
+ return error_response(cmd_id, str(e))
25
+
26
+
27
+ # ---------------------------------------------------------------------------
28
+ # Helpers
29
+ # ---------------------------------------------------------------------------
30
+
31
+ def _resolve_ref(manager: BrowserManager, ref_str: str):
32
+ """Resolve a ref string to a (locator, entry) tuple, or raise."""
33
+ entry = manager.refs.resolve(ref_str)
34
+ if entry is None:
35
+ raise ValueError(f"Ref @{ref_str.lstrip('@')} not found. Run 'camoufox-cli snapshot' to refresh refs.")
36
+ page = manager.get_page()
37
+ locator = page.get_by_role(entry.role, name=entry.name)
38
+ if entry.nth > 0:
39
+ locator = locator.nth(entry.nth)
40
+ return locator
41
+
42
+
43
+ # ---------------------------------------------------------------------------
44
+ # Navigation
45
+ # ---------------------------------------------------------------------------
46
+
47
+ def _cmd_open(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
48
+ url = params.get("url", "")
49
+ if not url:
50
+ return error_response(cmd_id, "Missing 'url' parameter")
51
+
52
+ if not manager.is_running:
53
+ manager.launch(headless=params.get("headless", True))
54
+
55
+ page = manager.get_page()
56
+ page.goto(url, wait_until="domcontentloaded")
57
+ return ok_response(cmd_id, {"url": page.url, "title": page.title()})
58
+
59
+
60
+ def _cmd_back(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
61
+ manager.get_page().go_back(wait_until="domcontentloaded")
62
+ return ok_response(cmd_id)
63
+
64
+
65
+ def _cmd_forward(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
66
+ manager.get_page().go_forward(wait_until="domcontentloaded")
67
+ return ok_response(cmd_id)
68
+
69
+
70
+ def _cmd_reload(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
71
+ page = manager.get_page()
72
+ page.evaluate("location.reload()")
73
+ page.wait_for_load_state("domcontentloaded")
74
+ return ok_response(cmd_id)
75
+
76
+
77
+ def _cmd_url(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
78
+ return ok_response(cmd_id, {"url": manager.get_page().url})
79
+
80
+
81
+ def _cmd_title(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
82
+ return ok_response(cmd_id, {"title": manager.get_page().title()})
83
+
84
+
85
+ def _cmd_close(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
86
+ manager.close()
87
+ return ok_response(cmd_id, {"closed": True})
88
+
89
+
90
+ # ---------------------------------------------------------------------------
91
+ # Snapshot
92
+ # ---------------------------------------------------------------------------
93
+
94
+ def _cmd_snapshot(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
95
+ page = manager.get_page()
96
+ interactive = params.get("interactive", False)
97
+ selector = params.get("selector")
98
+
99
+ target = page.locator(selector) if selector else page.locator("body")
100
+ aria_text = target.aria_snapshot()
101
+ annotated = manager.refs.build_from_snapshot(aria_text, interactive_only=interactive)
102
+ return ok_response(cmd_id, {"snapshot": annotated})
103
+
104
+
105
+ # ---------------------------------------------------------------------------
106
+ # Interaction
107
+ # ---------------------------------------------------------------------------
108
+
109
+ def _cmd_click(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
110
+ ref_str = params.get("ref", "")
111
+ if not ref_str:
112
+ return error_response(cmd_id, "Missing 'ref' parameter")
113
+ _resolve_ref(manager, ref_str).click()
114
+ return ok_response(cmd_id)
115
+
116
+
117
+ def _cmd_fill(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
118
+ ref_str = params.get("ref", "")
119
+ text = params.get("text", "")
120
+ if not ref_str:
121
+ return error_response(cmd_id, "Missing 'ref' parameter")
122
+ _resolve_ref(manager, ref_str).fill(text)
123
+ return ok_response(cmd_id)
124
+
125
+
126
+ def _cmd_type(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
127
+ ref_str = params.get("ref", "")
128
+ text = params.get("text", "")
129
+ if not ref_str:
130
+ return error_response(cmd_id, "Missing 'ref' parameter")
131
+ _resolve_ref(manager, ref_str).press_sequentially(text)
132
+ return ok_response(cmd_id)
133
+
134
+
135
+ def _cmd_select(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
136
+ ref_str = params.get("ref", "")
137
+ value = params.get("value", "")
138
+ if not ref_str:
139
+ return error_response(cmd_id, "Missing 'ref' parameter")
140
+ _resolve_ref(manager, ref_str).select_option(label=value)
141
+ return ok_response(cmd_id)
142
+
143
+
144
+ def _cmd_check(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
145
+ ref_str = params.get("ref", "")
146
+ if not ref_str:
147
+ return error_response(cmd_id, "Missing 'ref' parameter")
148
+ locator = _resolve_ref(manager, ref_str)
149
+ if locator.is_checked():
150
+ locator.uncheck()
151
+ else:
152
+ locator.check()
153
+ return ok_response(cmd_id)
154
+
155
+
156
+ def _cmd_hover(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
157
+ ref_str = params.get("ref", "")
158
+ if not ref_str:
159
+ return error_response(cmd_id, "Missing 'ref' parameter")
160
+ _resolve_ref(manager, ref_str).hover()
161
+ return ok_response(cmd_id)
162
+
163
+
164
+ def _cmd_press(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
165
+ key = params.get("key", "")
166
+ if not key:
167
+ return error_response(cmd_id, "Missing 'key' parameter")
168
+ manager.get_page().keyboard.press(key)
169
+ return ok_response(cmd_id)
170
+
171
+
172
+ # ---------------------------------------------------------------------------
173
+ # Data extraction
174
+ # ---------------------------------------------------------------------------
175
+
176
+ def _cmd_text(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
177
+ target = params.get("target", "")
178
+ if not target:
179
+ return error_response(cmd_id, "Missing 'target' parameter")
180
+
181
+ if target.startswith("@"):
182
+ text = _resolve_ref(manager, target).text_content() or ""
183
+ else:
184
+ text = manager.get_page().locator(target).text_content() or ""
185
+
186
+ return ok_response(cmd_id, {"text": text})
187
+
188
+
189
+ def _cmd_eval(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
190
+ expression = params.get("expression", "")
191
+ if not expression:
192
+ return error_response(cmd_id, "Missing 'expression' parameter")
193
+ result = manager.get_page().evaluate(expression)
194
+ return ok_response(cmd_id, {"result": result})
195
+
196
+
197
+ def _cmd_screenshot(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
198
+ page = manager.get_page()
199
+ path = params.get("path")
200
+ full_page = params.get("full_page", False)
201
+
202
+ if path:
203
+ page.screenshot(path=path, full_page=full_page)
204
+ return ok_response(cmd_id, {"path": path})
205
+ else:
206
+ buf = page.screenshot(full_page=full_page)
207
+ b64 = base64.b64encode(buf).decode("ascii")
208
+ return ok_response(cmd_id, {"base64": b64})
209
+
210
+
211
+ def _cmd_pdf(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
212
+ return error_response(
213
+ cmd_id,
214
+ "PDF export is not supported with Firefox/Camoufox. Use 'screenshot --full' instead.",
215
+ )
216
+
217
+
218
+ # ---------------------------------------------------------------------------
219
+ # Scroll & Wait
220
+ # ---------------------------------------------------------------------------
221
+
222
+ def _cmd_scroll(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
223
+ direction = params.get("direction", "down")
224
+ amount = int(params.get("amount", 500))
225
+
226
+ if direction == "up":
227
+ amount = -amount
228
+
229
+ manager.get_page().evaluate(f"window.scrollBy(0, {amount})")
230
+ return ok_response(cmd_id)
231
+
232
+
233
+ def _cmd_wait(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
234
+ page = manager.get_page()
235
+
236
+ if "ms" in params:
237
+ page.wait_for_timeout(int(params["ms"]))
238
+ elif "ref" in params:
239
+ _resolve_ref(manager, params["ref"]).wait_for()
240
+ elif "selector" in params:
241
+ page.wait_for_selector(params["selector"])
242
+ elif "url" in params:
243
+ page.wait_for_url(params["url"])
244
+ else:
245
+ return error_response(cmd_id, "wait requires ms, ref, selector, or url parameter")
246
+
247
+ return ok_response(cmd_id)
248
+
249
+
250
+ # ---------------------------------------------------------------------------
251
+ # Tab management
252
+ # ---------------------------------------------------------------------------
253
+
254
+ def _cmd_tabs(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
255
+ return ok_response(cmd_id, {"tabs": manager.get_tabs()})
256
+
257
+
258
+ def _cmd_switch(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
259
+ index = params.get("index")
260
+ if index is None:
261
+ return error_response(cmd_id, "Missing 'index' parameter")
262
+ page = manager.switch_to_tab(int(index))
263
+ return ok_response(cmd_id, {"url": page.url, "title": page.title()})
264
+
265
+
266
+ def _cmd_close_tab(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
267
+ manager.close_current_tab()
268
+ page = manager.get_page()
269
+ return ok_response(cmd_id, {"url": page.url, "title": page.title()})
270
+
271
+
272
+ # ---------------------------------------------------------------------------
273
+ # Cookies
274
+ # ---------------------------------------------------------------------------
275
+
276
+ def _cmd_cookies(manager: BrowserManager, cmd_id: str, params: dict) -> dict:
277
+ ctx = manager.get_context()
278
+ op = params.get("op", "list")
279
+
280
+ if op == "list":
281
+ cookies = ctx.cookies()
282
+ return ok_response(cmd_id, {"cookies": cookies})
283
+
284
+ elif op == "export":
285
+ path = params.get("path", "")
286
+ if not path:
287
+ return error_response(cmd_id, "Missing 'path' parameter for export")
288
+ cookies = ctx.cookies()
289
+ with open(path, "w") as f:
290
+ json.dump(cookies, f, indent=2)
291
+ return ok_response(cmd_id, {"path": path, "count": len(cookies)})
292
+
293
+ elif op == "import":
294
+ path = params.get("path", "")
295
+ if not path:
296
+ return error_response(cmd_id, "Missing 'path' parameter for import")
297
+ with open(path) as f:
298
+ cookies = json.load(f)
299
+ ctx.add_cookies(cookies)
300
+ return ok_response(cmd_id, {"count": len(cookies)})
301
+
302
+ else:
303
+ return error_response(cmd_id, f"Unknown cookies op: {op}")
304
+
305
+
306
+ # ---------------------------------------------------------------------------
307
+ # Handler dispatch table
308
+ # ---------------------------------------------------------------------------
309
+
310
+ _HANDLERS = {
311
+ # Navigation
312
+ "open": _cmd_open,
313
+ "back": _cmd_back,
314
+ "forward": _cmd_forward,
315
+ "reload": _cmd_reload,
316
+ "url": _cmd_url,
317
+ "title": _cmd_title,
318
+ "close": _cmd_close,
319
+ # Snapshot
320
+ "snapshot": _cmd_snapshot,
321
+ # Interaction
322
+ "click": _cmd_click,
323
+ "fill": _cmd_fill,
324
+ "type": _cmd_type,
325
+ "select": _cmd_select,
326
+ "check": _cmd_check,
327
+ "hover": _cmd_hover,
328
+ "press": _cmd_press,
329
+ # Data extraction
330
+ "text": _cmd_text,
331
+ "eval": _cmd_eval,
332
+ "screenshot": _cmd_screenshot,
333
+ "pdf": _cmd_pdf,
334
+ # Scroll & Wait
335
+ "scroll": _cmd_scroll,
336
+ "wait": _cmd_wait,
337
+ # Tab management
338
+ "tabs": _cmd_tabs,
339
+ "switch": _cmd_switch,
340
+ "close-tab": _cmd_close_tab,
341
+ # Cookies
342
+ "cookies": _cmd_cookies,
343
+ }
@@ -0,0 +1,24 @@
1
+ """JSON-line protocol for CLI <-> Daemon communication."""
2
+
3
+ import json
4
+
5
+
6
+ def parse_command(line: str) -> dict:
7
+ """Parse a JSON-line command from the CLI."""
8
+ return json.loads(line.strip())
9
+
10
+
11
+ def serialize_response(response: dict) -> bytes:
12
+ """Serialize a response dict to JSON-line bytes."""
13
+ return json.dumps(response, ensure_ascii=False).encode("utf-8") + b"\n"
14
+
15
+
16
+ def ok_response(id: str, data: dict | None = None) -> dict:
17
+ resp = {"id": id, "success": True}
18
+ if data is not None:
19
+ resp["data"] = data
20
+ return resp
21
+
22
+
23
+ def error_response(id: str, error: str) -> dict:
24
+ return {"id": id, "success": False, "error": error}
camoufox_cli/refs.py ADDED
@@ -0,0 +1,83 @@
1
+ """Ref registry: maps @e1, @e2 to aria role+name for Playwright locators."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import re
6
+ from dataclasses import dataclass
7
+
8
+
9
+ @dataclass
10
+ class RefEntry:
11
+ ref: str # e.g. "e1"
12
+ role: str # e.g. "link"
13
+ name: str # e.g. "About"
14
+ nth: int = 0 # index for duplicates
15
+
16
+
17
+ # Roles considered "interactive" for snapshot -i
18
+ INTERACTIVE_ROLES = frozenset({
19
+ "link", "button", "combobox", "textbox", "textarea",
20
+ "checkbox", "radio", "switch", "slider",
21
+ "tab", "tabpanel", "menuitem", "option",
22
+ "select", "listbox", "searchbox",
23
+ })
24
+
25
+ # Pattern to match aria snapshot lines like: - link "About"
26
+ # handles nested indentation and optional attributes
27
+ _ARIA_LINE_RE = re.compile(
28
+ r'^(\s*-\s+)' # leading indent + dash
29
+ r'(\w+)' # role
30
+ r'(?:\s+"([^"]*)")?' # optional quoted name
31
+ )
32
+
33
+
34
+ class RefRegistry:
35
+ def __init__(self):
36
+ self._entries: dict[str, RefEntry] = {} # ref_str -> RefEntry
37
+ self._counter = 0
38
+
39
+ def build_from_snapshot(self, aria_text: str, interactive_only: bool = False) -> str:
40
+ """Parse aria snapshot text, assign refs, return annotated text."""
41
+ self._entries.clear()
42
+ self._counter = 0
43
+
44
+ # Track role+name occurrences for nth disambiguation
45
+ seen: dict[tuple[str, str], int] = {}
46
+ lines = aria_text.split("\n")
47
+ result_lines = []
48
+
49
+ for line in lines:
50
+ m = _ARIA_LINE_RE.match(line)
51
+ if not m:
52
+ if not interactive_only:
53
+ result_lines.append(line)
54
+ continue
55
+
56
+ role = m.group(2)
57
+ name = m.group(3) or ""
58
+
59
+ if interactive_only and role not in INTERACTIVE_ROLES:
60
+ continue
61
+
62
+ key = (role, name)
63
+ nth = seen.get(key, 0)
64
+ seen[key] = nth + 1
65
+
66
+ self._counter += 1
67
+ ref = f"e{self._counter}"
68
+ entry = RefEntry(ref=ref, role=role, name=name, nth=nth)
69
+ self._entries[ref] = entry
70
+
71
+ # Append [ref=eN] to the line
72
+ annotated = f"{line.rstrip()} [ref={ref}]"
73
+ result_lines.append(annotated)
74
+
75
+ return "\n".join(result_lines)
76
+
77
+ def resolve(self, ref_str: str) -> RefEntry | None:
78
+ """Resolve a ref string like 'e1' or '@e1' to a RefEntry."""
79
+ ref = ref_str.lstrip("@")
80
+ return self._entries.get(ref)
81
+
82
+ def __len__(self) -> int:
83
+ return len(self._entries)
camoufox_cli/server.py ADDED
@@ -0,0 +1,145 @@
1
+ """Unix socket server for the camoufox-cli daemon."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import signal
7
+ import socket
8
+ import sys
9
+ import threading
10
+ import time
11
+
12
+ from .browser import BrowserManager
13
+ from .commands import execute
14
+ from .protocol import parse_command, serialize_response
15
+
16
+
17
+ class DaemonServer:
18
+ def __init__(self, session: str = "default", headless: bool = True, timeout: int = 1800, persistent: str | None = None):
19
+ self.session = session
20
+ self.headless = headless
21
+ self.timeout = timeout # idle timeout in seconds
22
+ self.socket_path = f"/tmp/camoufox-cli-{session}.sock"
23
+ self.pid_path = f"/tmp/camoufox-cli-{session}.pid"
24
+ self.manager = BrowserManager(persistent=persistent)
25
+ self._server_socket: socket.socket | None = None
26
+ self._last_activity = time.time()
27
+ self._running = False
28
+
29
+ def start(self) -> None:
30
+ self._cleanup_stale()
31
+ self._write_pid()
32
+ self._running = True
33
+
34
+ # Start idle timeout watchdog
35
+ watchdog = threading.Thread(target=self._idle_watchdog, daemon=True)
36
+ watchdog.start()
37
+
38
+ # Set up signal handlers
39
+ signal.signal(signal.SIGTERM, self._handle_signal)
40
+ signal.signal(signal.SIGINT, self._handle_signal)
41
+
42
+ self._server_socket = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
43
+ try:
44
+ self._server_socket.bind(self.socket_path)
45
+ self._server_socket.listen(5)
46
+ self._server_socket.settimeout(1.0) # allow periodic checks
47
+
48
+ while self._running:
49
+ try:
50
+ conn, _ = self._server_socket.accept()
51
+ except socket.timeout:
52
+ continue
53
+ except OSError:
54
+ break
55
+
56
+ self._last_activity = time.time()
57
+ try:
58
+ self._handle_connection(conn)
59
+ except Exception as e:
60
+ print(f"[camoufox-cli] Connection error: {e}", file=sys.stderr)
61
+ finally:
62
+ conn.close()
63
+ finally:
64
+ self._shutdown()
65
+
66
+ def _handle_connection(self, conn: socket.socket) -> None:
67
+ data = b""
68
+ while True:
69
+ chunk = conn.recv(4096)
70
+ if not chunk:
71
+ break
72
+ data += chunk
73
+ if b"\n" in data:
74
+ break
75
+
76
+ line = data.decode("utf-8").strip()
77
+ if not line:
78
+ return
79
+
80
+ command = parse_command(line)
81
+
82
+ # Pass headless preference to open commands
83
+ if command.get("action") == "open":
84
+ command.setdefault("params", {}).setdefault("headless", self.headless)
85
+
86
+ response = execute(self.manager, command)
87
+ conn.sendall(serialize_response(response))
88
+
89
+ # If close command, shut down the daemon
90
+ if command.get("action") == "close":
91
+ self._running = False
92
+
93
+ def _idle_watchdog(self) -> None:
94
+ while self._running:
95
+ time.sleep(10)
96
+ if time.time() - self._last_activity > self.timeout:
97
+ print(f"[camoufox-cli] Idle timeout ({self.timeout}s), shutting down", file=sys.stderr)
98
+ self._running = False
99
+ # Nudge the accept() loop
100
+ try:
101
+ s = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM)
102
+ s.connect(self.socket_path)
103
+ s.close()
104
+ except Exception:
105
+ pass
106
+ break
107
+
108
+ def _handle_signal(self, signum, frame):
109
+ self._running = False
110
+
111
+ def _shutdown(self) -> None:
112
+ self.manager.close()
113
+ if self._server_socket:
114
+ try:
115
+ self._server_socket.close()
116
+ except Exception:
117
+ pass
118
+ self._cleanup_files()
119
+
120
+ def _cleanup_stale(self) -> None:
121
+ """Remove stale socket file if no daemon is running."""
122
+ if os.path.exists(self.socket_path):
123
+ # Check if another daemon is using it
124
+ if os.path.exists(self.pid_path):
125
+ try:
126
+ with open(self.pid_path) as f:
127
+ pid = int(f.read().strip())
128
+ os.kill(pid, 0)
129
+ # Process exists — abort
130
+ print(f"[camoufox-cli] Daemon already running (pid {pid})", file=sys.stderr)
131
+ sys.exit(1)
132
+ except (ProcessLookupError, ValueError):
133
+ pass # stale pid, clean up
134
+ os.unlink(self.socket_path)
135
+
136
+ def _write_pid(self) -> None:
137
+ with open(self.pid_path, "w") as f:
138
+ f.write(str(os.getpid()))
139
+
140
+ def _cleanup_files(self) -> None:
141
+ for path in (self.socket_path, self.pid_path):
142
+ try:
143
+ os.unlink(path)
144
+ except FileNotFoundError:
145
+ pass
@@ -0,0 +1,158 @@
1
+ Metadata-Version: 2.4
2
+ Name: camoufox-cli
3
+ Version: 0.1.0
4
+ Summary: Anti-detect browser CLI for AI agents, powered by Camoufox
5
+ Project-URL: Homepage, https://github.com/Bin-Huang/camoufox-cli
6
+ Project-URL: Repository, https://github.com/Bin-Huang/camoufox-cli
7
+ Project-URL: Issues, https://github.com/Bin-Huang/camoufox-cli/issues
8
+ Author: Bin Huang
9
+ License-Expression: MIT
10
+ License-File: LICENSE
11
+ Keywords: ai-agent,anti-detect,automation,browser,camoufox,headless
12
+ Classifier: Development Status :: 3 - Alpha
13
+ Classifier: Environment :: Console
14
+ Classifier: Intended Audience :: Developers
15
+ Classifier: Topic :: Internet :: WWW/HTTP :: Browsers
16
+ Classifier: Topic :: Software Development :: Testing
17
+ Requires-Python: >=3.10
18
+ Requires-Dist: camoufox[geoip]
19
+ Requires-Dist: playwright
20
+ Description-Content-Type: text/markdown
21
+
22
+ # camoufox-cli
23
+
24
+ Anti-detect browser CLI for AI agents, powered by [Camoufox](https://github.com/daijro/camoufox).
25
+
26
+ Camoufox has C++-level fingerprint spoofing (`navigator.webdriver=false`, randomized canvas/WebGL/audio, real plugins) but only exposes a Python API. This CLI wraps it into a simple command interface optimized for AI agent tool calls — snapshot the accessibility tree, interact by ref, repeat.
27
+
28
+ ## Install
29
+
30
+ ```bash
31
+ pipx install camoufox-cli
32
+ ```
33
+
34
+ Or with pip:
35
+
36
+ ```bash
37
+ pip install camoufox-cli
38
+ ```
39
+
40
+ ## Quick Start
41
+
42
+ ```bash
43
+ camoufox-cli open https://example.com # Launch browser & navigate
44
+ camoufox-cli snapshot -i # Interactive elements only
45
+ # - link "More information..." [ref=e1]
46
+ camoufox-cli click @e1 # Click by ref
47
+ camoufox-cli close # Done
48
+ ```
49
+
50
+ ## Commands
51
+
52
+ ### Navigation
53
+
54
+ ```bash
55
+ camoufox-cli open <url> # Navigate to URL (starts daemon if needed)
56
+ camoufox-cli back # Go back
57
+ camoufox-cli forward # Go forward
58
+ camoufox-cli reload # Reload page
59
+ camoufox-cli url # Print current URL
60
+ camoufox-cli title # Print page title
61
+ camoufox-cli close # Close browser and stop daemon
62
+ ```
63
+
64
+ ### Snapshot
65
+
66
+ ```bash
67
+ camoufox-cli snapshot # Full accessibility tree
68
+ camoufox-cli snapshot -i # Interactive elements only
69
+ camoufox-cli snapshot -s "css-selector" # Scoped to CSS selector
70
+ ```
71
+
72
+ Output format:
73
+
74
+ ```
75
+ - heading "Example Domain" [level=1] [ref=e1]
76
+ - paragraph [ref=e2]
77
+ - link "More information..." [ref=e3]
78
+ ```
79
+
80
+ ### Interaction
81
+
82
+ ```bash
83
+ camoufox-cli click @e1 # Click element
84
+ camoufox-cli fill @e3 "search query" # Clear + type into input
85
+ camoufox-cli type @e3 "append text" # Type without clearing
86
+ camoufox-cli select @e5 "option text" # Select dropdown option
87
+ camoufox-cli check @e6 # Toggle checkbox
88
+ camoufox-cli hover @e2 # Hover over element
89
+ camoufox-cli press Enter # Press keyboard key
90
+ camoufox-cli press "Control+a" # Key combination
91
+ ```
92
+
93
+ ### Data Extraction
94
+
95
+ ```bash
96
+ camoufox-cli text @e1 # Get text content of element
97
+ camoufox-cli text body # Get all page text
98
+ camoufox-cli eval "document.title" # Execute JavaScript
99
+ camoufox-cli screenshot # Screenshot (base64 to stdout)
100
+ camoufox-cli screenshot page.png # Screenshot to file
101
+ camoufox-cli screenshot --full page.png # Full page screenshot
102
+ ```
103
+
104
+ ### Scroll & Wait
105
+
106
+ ```bash
107
+ camoufox-cli scroll down # Scroll down 500px
108
+ camoufox-cli scroll up 1000 # Scroll up 1000px
109
+ camoufox-cli wait 2000 # Wait milliseconds
110
+ camoufox-cli wait @e1 # Wait for element to appear
111
+ camoufox-cli wait --url "*/dashboard" # Wait for URL pattern
112
+ ```
113
+
114
+ ### Tabs
115
+
116
+ ```bash
117
+ camoufox-cli tabs # List open tabs
118
+ camoufox-cli switch 2 # Switch to tab by index
119
+ camoufox-cli close-tab # Close current tab
120
+ ```
121
+
122
+ ### Sessions
123
+
124
+ ```bash
125
+ camoufox-cli sessions # List active sessions
126
+ camoufox-cli --session work open <url> # Use named session
127
+ camoufox-cli close --all # Close all sessions
128
+ ```
129
+
130
+ ### Cookies
131
+
132
+ ```bash
133
+ camoufox-cli cookies # Dump cookies as JSON
134
+ camoufox-cli cookies import file.json # Import cookies
135
+ camoufox-cli cookies export file.json # Export cookies
136
+ ```
137
+
138
+ ## Flags
139
+
140
+ ```
141
+ --session <name> Named session (default: "default")
142
+ --headed Show browser window (default: headless)
143
+ --timeout <seconds> Daemon idle timeout (default: 1800)
144
+ --json Output as JSON
145
+ --persistent <path> Use persistent browser profile directory
146
+ ```
147
+
148
+ ## Architecture
149
+
150
+ ```
151
+ CLI (camoufox-cli) ──Unix socket──▶ Daemon (Python) ──Playwright──▶ Camoufox (Firefox)
152
+ ```
153
+
154
+ The CLI sends JSON commands to a long-running daemon process via Unix socket. The daemon manages the Camoufox browser instance and maintains the ref registry between commands. The daemon auto-starts on the first command and auto-stops after 30 minutes of inactivity.
155
+
156
+ ## License
157
+
158
+ MIT
@@ -0,0 +1,13 @@
1
+ camoufox_cli/__init__.py,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
2
+ camoufox_cli/__main__.py,sha256=LW3Uey5g-nZ09cxETsLRs13KMxxWmTmhzEu0BInHeZA,1039
3
+ camoufox_cli/browser.py,sha256=Urol-couWtcg4BxtlsPuqg-fnYByPRkvW1uEAtcMLoM,3289
4
+ camoufox_cli/cli.py,sha256=Qu5BDTDTRsHDCEIC7qkU7ipMw0kfz1TkiTFIqkwS4ro,13777
5
+ camoufox_cli/commands.py,sha256=UxdAbGDu0KOYZIdCc511-3_Otl4PykSHhHH66b1eLpo,11556
6
+ camoufox_cli/protocol.py,sha256=vRpQPLxS4s5chA57bqdM9MgrP-wTlRGmiZm2V-QMtuI,658
7
+ camoufox_cli/refs.py,sha256=2qldrUQe3LWXvEAGvF40VjomTWui8EzfUg_GTaGeR_M,2509
8
+ camoufox_cli/server.py,sha256=p4rL-ha83RnRf8CnLJ7kjEB32ocMAxInSEgJ8G6kWak,4888
9
+ camoufox_cli-0.1.0.dist-info/METADATA,sha256=dLwSD9MgcddbClVMldRDsZhEN22r9L04h9R-PmKAAgo,5171
10
+ camoufox_cli-0.1.0.dist-info/WHEEL,sha256=QccIxa26bgl1E6uMy58deGWi-0aeIkkangHcxk2kWfw,87
11
+ camoufox_cli-0.1.0.dist-info/entry_points.txt,sha256=xB8yicErztODnNm6aPmBX00JC46jh4HvwtppdoG0op0,55
12
+ camoufox_cli-0.1.0.dist-info/licenses/LICENSE,sha256=oW4W62E_nL4Ybo4equL-rDJ-7R6zX-e4shGYeIWj86I,1066
13
+ camoufox_cli-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,4 @@
1
+ Wheel-Version: 1.0
2
+ Generator: hatchling 1.29.0
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ camoufox-cli = camoufox_cli.cli:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2025 Bin Huang
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.