real-browser-cli 0.14.2__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.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -0,0 +1,96 @@
1
+ import click
2
+ from browser_cli.commands import client_from_ctx, handle_errors, tab_option
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+ @click.group("nav")
8
+ def nav_group():
9
+ """Navigate — open URLs, reload, go back/forward, focus tabs."""
10
+
11
+ @nav_group.command("open")
12
+ @click.argument("url")
13
+ @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
14
+ @click.option("--window", "window_name", default=None, help="Open in named window")
15
+ @click.option("--group", "group_name", default=None, help="Open directly into a tab group (name or ID)")
16
+ @click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
17
+ @click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
18
+ @click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
19
+ @handle_errors
20
+ def cmd_open(url, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
21
+ """Open URL in a new tab without stealing focus by default."""
22
+ client_from_ctx().nav.open(url, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
23
+ suffix = ""
24
+ if group_name:
25
+ suffix = f" in group '{group_name}'"
26
+ elif window_name:
27
+ suffix = f" in window '{window_name}'"
28
+ console.print(f"[green]Opened:[/green] {url}{suffix}")
29
+
30
+ @nav_group.command("reload")
31
+ @click.argument("tab_id", type=int, required=False)
32
+ @handle_errors
33
+ def cmd_reload(tab_id):
34
+ """Reload the active (or specified) tab."""
35
+ client_from_ctx().nav.reload(tab_id)
36
+ console.print("[green]Reloaded[/green]")
37
+
38
+ @nav_group.command("hard-reload")
39
+ @click.argument("tab_id", type=int, required=False)
40
+ @handle_errors
41
+ def cmd_hard_reload(tab_id):
42
+ """Hard reload (bypass cache) the active (or specified) tab."""
43
+ client_from_ctx().nav.hard_reload(tab_id)
44
+ console.print("[green]Hard reloaded[/green]")
45
+
46
+ @nav_group.command("back")
47
+ @click.argument("tab_id", type=int, required=False)
48
+ @handle_errors
49
+ def cmd_back(tab_id):
50
+ """Navigate back in the active (or specified) tab."""
51
+ client_from_ctx().nav.back(tab_id)
52
+ console.print("[green]Navigated back[/green]")
53
+
54
+ @nav_group.command("forward")
55
+ @click.argument("tab_id", type=int, required=False)
56
+ @handle_errors
57
+ def cmd_forward(tab_id):
58
+ """Navigate forward in the active (or specified) tab."""
59
+ client_from_ctx().nav.forward(tab_id)
60
+ console.print("[green]Navigated forward[/green]")
61
+
62
+ @nav_group.command("focus")
63
+ @click.argument("pattern")
64
+ @handle_errors
65
+ def cmd_focus(pattern):
66
+ """Jump to a tab by URL pattern or tab ID."""
67
+ result = client_from_ctx().nav.focus(pattern)
68
+ if result:
69
+ console.print(f"[green]Focused:[/green] {result.get('url', result)}")
70
+ else:
71
+ console.print(f"[yellow]No tab found matching:[/yellow] {pattern}")
72
+
73
+ @nav_group.command("open-wait")
74
+ @click.argument("url")
75
+ @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait for load")
76
+ @click.option("--focus", is_flag=True, help="Bring the opened tab/window to the front")
77
+ @click.option("--window", "window_name", default=None, help="Open in named window")
78
+ @click.option("--group", "group_name", default=None, help="Open in tab group")
79
+ @click.option("--reuse", is_flag=True, help="Reuse an existing tab with exactly this URL")
80
+ @click.option("--reuse-domain", is_flag=True, help="Reuse an existing tab with the same domain")
81
+ @click.option("--reuse-title", default=None, metavar="TEXT", help="Reuse an existing tab whose title contains TEXT")
82
+ @handle_errors
83
+ def cmd_open_wait(url, timeout, focus, window_name, group_name, reuse, reuse_domain, reuse_title):
84
+ """Open URL in a new tab and wait until fully loaded."""
85
+ tab = client_from_ctx().nav.open_wait(url, timeout=timeout, focus=focus, window=window_name, group=group_name, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
86
+ console.print(f"[green]Loaded:[/green] {url}" + (f" — {tab.title}" if tab.title else ""))
87
+
88
+ @nav_group.command("wait")
89
+ @tab_option
90
+ @click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
91
+ @click.option("--ready-state", type=click.Choice(["complete", "interactive"]), default="complete", show_default=True, help="Target ready state")
92
+ @handle_errors
93
+ def cmd_wait(tab_id, timeout, ready_state):
94
+ """Wait until tab finishes loading."""
95
+ tab = client_from_ctx().tabs.wait_for_load(tab_id, timeout=timeout, ready_state=ready_state)
96
+ console.print(f"[green]Ready:[/green] {tab.url} — {tab.title}")
@@ -0,0 +1,26 @@
1
+ import click
2
+ from browser_cli.commands import client_from_ctx, handle_errors
3
+ from rich.console import Console
4
+ from rich.table import Table
5
+
6
+ console = Console()
7
+
8
+ @click.group("page")
9
+ def page_group():
10
+ """Inspect current page metadata."""
11
+
12
+ @page_group.command("info")
13
+ @handle_errors
14
+ def page_info():
15
+ """Show title, URL, readyState, language, and meta tags of the active tab."""
16
+ info = client_from_ctx().page.info()
17
+ table = Table(show_header=False)
18
+ table.add_column("Field", style="bold cyan", no_wrap=True)
19
+ table.add_column("Value")
20
+ table.add_row("Title", info.get("title") or "")
21
+ table.add_row("URL", info.get("url") or "")
22
+ table.add_row("Ready", info.get("readyState") or "")
23
+ table.add_row("Lang", info.get("lang") or "")
24
+ for key, val in (info.get("meta") or {}).items():
25
+ table.add_row(f"meta:{key}", val)
26
+ console.print(table)
@@ -0,0 +1,47 @@
1
+ import click
2
+ from rich.console import Console
3
+ from rich.table import Table
4
+ from browser_cli.commands import client_from_ctx, handle_errors
5
+
6
+ console = Console()
7
+
8
+ @click.group("perf")
9
+ def perf_group():
10
+ """Inspect and tune browser-cli performance behavior."""
11
+
12
+ @perf_group.command("status")
13
+ @handle_errors
14
+ def perf_status():
15
+ """Show performance profile, throttle and running jobs."""
16
+ result = client_from_ctx().perf.status()
17
+ console.print(f"Profile: [bold]{result.get('performanceProfile', 'auto')}[/bold]")
18
+ console.print(f"Audible tabs: {'yes' if result.get('audible') else 'no'}")
19
+ throttle = result.get("throttle") or {}
20
+ console.print(f"Throttle: batch={throttle.get('batchSize')} pause={throttle.get('pauseMs')}ms mode={throttle.get('mode')}")
21
+
22
+ jobs = result.get("jobs") or []
23
+ if not jobs:
24
+ console.print("[yellow]No running jobs[/yellow]")
25
+ return
26
+
27
+ table = Table(show_header=True, header_style="bold cyan")
28
+ table.add_column("Job")
29
+ table.add_column("Command")
30
+ table.add_column("Status")
31
+ table.add_column("Progress", justify="right")
32
+ table.add_column("Phase")
33
+ for job in jobs:
34
+ total = job.get("total")
35
+ current = job.get("current") or 0
36
+ percent = job.get("percent") or 0
37
+ progress = f"{current}/{total} ({percent}%)" if total else f"{percent}%"
38
+ table.add_row(job.get("id", ""), job.get("command", ""), job.get("status", ""), progress, job.get("phase", ""))
39
+ console.print(table)
40
+
41
+ @perf_group.command("profile")
42
+ @click.argument("profile", type=click.Choice(["auto", "normal", "gentle", "ultra"]))
43
+ @handle_errors
44
+ def perf_profile(profile):
45
+ """Set global performance profile."""
46
+ result = client_from_ctx().perf.set_profile(profile)
47
+ console.print(f"[green]Performance profile set to {result.get('performanceProfile', profile)}[/green]")
@@ -0,0 +1,23 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+
7
+ from browser_cli.command_security import CommandPolicy, assert_command_allowed
8
+ from browser_cli.commands import client_from_ctx, handle_errors
9
+
10
+ @click.command("command")
11
+ @click.argument("name")
12
+ @click.argument("args_json", required=False, default="{}")
13
+ @click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
14
+ @click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
15
+ @click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
16
+ @handle_errors
17
+ def cmd_command(name, args_json, allow_read_page, allow_control, allow_dangerous):
18
+ """Send a raw browser-cli wire command and print JSON."""
19
+ policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
20
+ assert_command_allowed(name, policy)
21
+ args = json.loads(args_json) if args_json else {}
22
+ result = client_from_ctx().command(name, args)
23
+ click.echo(json.dumps(result, indent=2, default=str))
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+
5
+ import click
6
+ from rich.console import Console
7
+ from rich.table import Table
8
+
9
+ from browser_cli import BrowserCLI
10
+ from browser_cli.commands import handle_errors
11
+ from browser_cli.remote.registry import REMOTE_REGISTRY_PATH, load_remotes, save_remote_key
12
+
13
+ console = Console()
14
+
15
+ @click.group("remote")
16
+ def remote_group():
17
+ """Manage remembered browser-cli remote endpoints."""
18
+
19
+ @remote_group.command("status")
20
+ @click.argument("endpoint")
21
+ @click.option("--key", default=None, help="Key spec/path to use for this probe")
22
+ @handle_errors
23
+ def remote_status(endpoint, key):
24
+ """Probe a remote endpoint and show server/client status."""
25
+ client = BrowserCLI(remote=endpoint, key=key)
26
+ clients = client.clients()
27
+ table = Table(show_header=True, header_style="bold cyan")
28
+ table.add_column("Profile")
29
+ table.add_column("Browser")
30
+ table.add_column("Extension")
31
+ for item in clients:
32
+ table.add_row(str(item.get("profile", "")), str(item.get("name", "")), str(item.get("extensionVersion", "")))
33
+ console.print(table)
34
+
35
+ @remote_group.command("trust")
36
+ @click.argument("endpoint")
37
+ @click.argument("key_spec")
38
+ def remote_trust(endpoint, key_spec):
39
+ """Remember which key spec to use for ENDPOINT."""
40
+ save_remote_key(endpoint, key_spec)
41
+ console.print(f"[green]Trusted remote {endpoint} with key {key_spec}[/green]")
42
+
43
+ @remote_group.command("keys")
44
+ def remote_keys():
45
+ """List remembered remote key specs."""
46
+ remotes = load_remotes()
47
+ if not remotes:
48
+ console.print("[yellow]No remembered remotes[/yellow]")
49
+ return
50
+ table = Table(show_header=True, header_style="bold cyan")
51
+ table.add_column("Endpoint")
52
+ table.add_column("Key")
53
+ for endpoint, cfg in sorted(remotes.items()):
54
+ table.add_row(endpoint, str(cfg.get("key", "")))
55
+ console.print(table)
56
+
57
+ @remote_group.command("revoke")
58
+ @click.argument("endpoint")
59
+ def remote_revoke(endpoint):
60
+ """Remove remembered key/config for ENDPOINT."""
61
+ remotes = load_remotes()
62
+ if endpoint not in remotes:
63
+ console.print(f"[yellow]Remote {endpoint} not remembered[/yellow]")
64
+ return
65
+ del remotes[endpoint]
66
+ REMOTE_REGISTRY_PATH.parent.mkdir(parents=True, exist_ok=True)
67
+ REMOTE_REGISTRY_PATH.write_text(json.dumps(remotes, indent=2, sort_keys=True) + "\n", encoding="utf-8")
68
+ console.print(f"[green]Revoked {endpoint}[/green]")
@@ -0,0 +1,68 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ from pathlib import Path
5
+
6
+ import click
7
+ from rich.console import Console
8
+
9
+ from browser_cli.command_security import CommandPolicy, assert_command_allowed
10
+ from browser_cli.commands import client_from_ctx, handle_errors
11
+
12
+ console = Console()
13
+
14
+ def _load_steps(path: Path):
15
+ text = path.read_text(encoding="utf-8")
16
+ if path.suffix.lower() in {".yaml", ".yml"}:
17
+ try:
18
+ import yaml # type: ignore
19
+ except Exception as exc:
20
+ raise click.ClickException("YAML scripts require PyYAML; use JSON or install PyYAML") from exc
21
+ return yaml.safe_load(text)
22
+ return json.loads(text)
23
+
24
+ def _parse_step(step):
25
+ if isinstance(step, str):
26
+ return step, {}
27
+ if isinstance(step, dict):
28
+ if "command" in step:
29
+ return step["command"], step.get("args") or {}
30
+ if len(step) == 1:
31
+ command, args = next(iter(step.items()))
32
+ return command, args or {}
33
+ raise click.ClickException(f"Invalid script step: {step!r}")
34
+
35
+ @click.command("script")
36
+ @click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
37
+ @click.option("--json", "json_output", is_flag=True, help="Print all step results as JSON")
38
+ @click.option("--continue-on-error", is_flag=True, help="Continue after failed steps")
39
+ @click.option("--allow-read-page", is_flag=True, help="Allow page-content read commands such as extract.* and dom.text")
40
+ @click.option("--allow-control", is_flag=True, help="Allow browser-control commands such as nav.*, tabs.close, dom.click")
41
+ @click.option("--allow-dangerous", is_flag=True, help="Allow high-risk commands such as dom.eval, storage.*, screenshots")
42
+ @handle_errors
43
+ def cmd_script(file: Path, json_output: bool, continue_on_error: bool, allow_read_page: bool, allow_control: bool, allow_dangerous: bool):
44
+ """Run a JSON/YAML batch script of browser-cli wire commands."""
45
+ steps = _load_steps(file)
46
+ if not isinstance(steps, list):
47
+ raise click.ClickException("Script root must be a list")
48
+ client = client_from_ctx()
49
+ policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
50
+ results = []
51
+ for index, step in enumerate(steps, start=1):
52
+ command, args = _parse_step(step)
53
+ try:
54
+ assert_command_allowed(command, policy)
55
+ result = client.command(command, args)
56
+ results.append({"index": index, "command": command, "ok": True, "result": result})
57
+ if not json_output:
58
+ console.print(f"[green]✓[/green] {index}: {command}")
59
+ except Exception as exc:
60
+ results.append({"index": index, "command": command, "ok": False, "error": str(exc)})
61
+ if not continue_on_error:
62
+ if json_output:
63
+ click.echo(json.dumps(results, indent=2, default=str))
64
+ raise
65
+ if not json_output:
66
+ console.print(f"[red]✗[/red] {index}: {command}: {exc}")
67
+ if json_output:
68
+ click.echo(json.dumps(results, indent=2, default=str))
@@ -0,0 +1,79 @@
1
+ import click
2
+ from browser_cli.commands import client_from_ctx, handle_errors
3
+ from rich.console import Console
4
+
5
+ console = Console()
6
+
7
+ ENGINES = {
8
+ "google": "https://www.google.com/search?q={query}",
9
+ "brave": "https://search.brave.com/search?q={query}",
10
+ "duckduckgo": "https://duckduckgo.com/?q={query}",
11
+ "ddg": "https://duckduckgo.com/?q={query}",
12
+ "youtube": "https://www.youtube.com/results?search_query={query}",
13
+ "yt": "https://www.youtube.com/results?search_query={query}",
14
+ "spotify": "https://open.spotify.com/search/{query}",
15
+ "amazon": "https://www.amazon.com/s?k={query}",
16
+ "ecosia": "https://www.ecosia.org/search?q={query}",
17
+ "furaffinity": "https://www.furaffinity.net/search/?q={query}",
18
+ "fa": "https://www.furaffinity.net/search/?q={query}",
19
+ "bing": "https://www.bing.com/search?q={query}",
20
+ "github": "https://github.com/search?q={query}",
21
+ "wikipedia": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
22
+ "wiki": "https://en.wikipedia.org/wiki/Special:Search?search={query}",
23
+ "reddit": "https://www.reddit.com/search/?q={query}",
24
+ "stackoverflow": "https://stackoverflow.com/search?q={query}",
25
+ "so": "https://stackoverflow.com/search?q={query}",
26
+ }
27
+
28
+ _DISPLAY_NAMES = {
29
+ "google": "Google", "brave": "Brave Search", "duckduckgo": "DuckDuckGo",
30
+ "ddg": "DuckDuckGo", "youtube": "YouTube", "yt": "YouTube",
31
+ "spotify": "Spotify", "amazon": "Amazon", "ecosia": "Ecosia",
32
+ "furaffinity": "FurAffinity", "fa": "FurAffinity", "bing": "Bing",
33
+ "github": "GitHub", "wikipedia": "Wikipedia", "wiki": "Wikipedia",
34
+ "reddit": "Reddit", "stackoverflow": "Stack Overflow", "so": "Stack Overflow",
35
+ }
36
+
37
+ _SUBCOMMANDS = [
38
+ ("google", "Search with Google."),
39
+ ("brave", "Search with Brave Search."),
40
+ ("duckduckgo", "Search with DuckDuckGo."),
41
+ ("ddg", "Search with DuckDuckGo (alias for duckduckgo)."),
42
+ ("youtube", "Search YouTube videos."),
43
+ ("yt", "Search YouTube (alias for youtube)."),
44
+ ("spotify", "Search Spotify."),
45
+ ("amazon", "Search Amazon."),
46
+ ("ecosia", "Search with Ecosia."),
47
+ ("furaffinity", "Search FurAffinity."),
48
+ ("fa", "Search FurAffinity (alias for furaffinity)."),
49
+ ("bing", "Search with Bing."),
50
+ ("github", "Search GitHub."),
51
+ ("wikipedia", "Search Wikipedia."),
52
+ ("wiki", "Search Wikipedia (alias for wikipedia)."),
53
+ ("reddit", "Search Reddit."),
54
+ ("stackoverflow", "Search Stack Overflow."),
55
+ ("so", "Search Stack Overflow (alias for stackoverflow)."),
56
+ ]
57
+
58
+
59
+ @click.group("search")
60
+ def search_group():
61
+ """Search the web — open a query in a search engine."""
62
+
63
+ def _build_command(engine_key: str, help_text: str) -> click.Command:
64
+ @click.command(engine_key, help=help_text)
65
+ @click.argument("query", nargs=-1, required=True)
66
+ @click.option("--window", "window", default=None, help="Open in named window")
67
+ @click.option("--group", "group", default=None, help="Open in tab group (name or ID)")
68
+ @handle_errors
69
+ def _cmd(query, window, group):
70
+ terms = " ".join(query)
71
+ client_from_ctx().nav.search(engine_key, terms, window=window, group=group)
72
+ suffix = f" in group '{group}'" if group else (f" in window '{window}'" if window else "")
73
+ display = _DISPLAY_NAMES.get(engine_key, engine_key.capitalize())
74
+ console.print(f"[green]Searching[/green] [cyan]{display}[/cyan]: {terms}{suffix}")
75
+
76
+ return _cmd
77
+
78
+ for _name, _help in _SUBCOMMANDS:
79
+ search_group.add_command(_build_command(_name, _help))
@@ -0,0 +1,117 @@
1
+ """Click command for exposing a browser over TCP."""
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import sys
6
+ from pathlib import Path
7
+
8
+ import click
9
+
10
+ from browser_cli import transport
11
+ from browser_cli.serve.runtime import (
12
+ _async_framed_send,
13
+ _async_handle_client,
14
+ _async_recv_all,
15
+ _handle_client,
16
+ _serve_async,
17
+ console,
18
+ )
19
+ from browser_cli.version_manager import get_installed_version
20
+
21
+ __all__ = [
22
+ "_async_framed_send",
23
+ "_async_handle_client",
24
+ "_async_recv_all",
25
+ "_handle_client",
26
+ "_serve_async",
27
+ "cmd_serve",
28
+ ]
29
+
30
+ @click.command("serve")
31
+ @click.option("--host", default="127.0.0.1", show_default=True, help="Address to bind.")
32
+ @click.option("--port", default=8765, show_default=True, type=int, help="TCP port to listen on.")
33
+ @click.option("--no-auth", is_flag=True, default=False, help="Disable authentication (dangerous).")
34
+ @click.option(
35
+ "--authorized-keys",
36
+ "auth_keys_file",
37
+ default=None,
38
+ metavar="FILE",
39
+ help="File of trusted Ed25519 public keys (one hex per line). Required unless --no-auth.",
40
+ )
41
+ @click.option(
42
+ "--no-compress",
43
+ "no_compress",
44
+ is_flag=True,
45
+ default=False,
46
+ help="Disable response compression / msgpack even for clients that support it.",
47
+ )
48
+ @click.pass_context
49
+ def cmd_serve(ctx, host, port, no_auth, auth_keys_file, no_compress):
50
+ """Expose this browser over TCP so remote hosts can control it."""
51
+ profile = ctx.obj.get("browser") if ctx.obj else None
52
+ compress = not no_compress
53
+
54
+ if host in ("0.0.0.0", "::"):
55
+ console.print(
56
+ "[yellow]Warning:[/yellow] Binding to all interfaces — "
57
+ "anyone who can reach this port controls your browser."
58
+ )
59
+
60
+ auth_keys_path = _resolve_auth_keys_path(auth_keys_file, no_auth)
61
+ if auth_keys_path is False:
62
+ sys.exit(1)
63
+
64
+ _print_startup(host, port, profile, auth_keys_path, compress)
65
+
66
+ try:
67
+ asyncio.run(_serve_async(host, port, profile, auth_keys_path, compress))
68
+ except OSError as e:
69
+ console.print(f"[red]Cannot bind to {host}:{port}:[/red] {e}")
70
+ sys.exit(1)
71
+ except KeyboardInterrupt:
72
+ console.print("[yellow]Stopped.[/yellow]")
73
+
74
+ def _resolve_auth_keys_path(auth_keys_file: str | None, no_auth: bool) -> Path | None | bool:
75
+ if auth_keys_file:
76
+ from browser_cli.auth import load_authorized_keys
77
+
78
+ auth_keys_path = Path(auth_keys_file)
79
+ if not load_authorized_keys(auth_keys_path):
80
+ console.print(f"[yellow]Warning:[/yellow] No authorized keys found in {auth_keys_path}")
81
+ return auth_keys_path
82
+ if no_auth:
83
+ return None
84
+ console.print(
85
+ "[red]Error:[/red] --authorized-keys FILE is required. "
86
+ "Use --no-auth to explicitly disable auth (dangerous)."
87
+ )
88
+ return False
89
+
90
+ def _print_startup(host: str, port: int, profile: str | None, auth_keys_path: Path | None, compress: bool) -> None:
91
+ current_ver = get_installed_version()
92
+ browser_hint = f" (browser: {profile})" if profile else ""
93
+ console.print(f"[green]Serving browser{browser_hint} →[/green] [cyan]{host}:{port}[/cyan] [dim]v{current_ver}[/dim]")
94
+
95
+ if auth_keys_path is not None:
96
+ from browser_cli.auth import load_authorized_keys
97
+
98
+ n = len(load_authorized_keys(auth_keys_path))
99
+ console.print(f" Auth: [bold green]Ed25519 pubkey[/bold green] ({n} trusted key{'s' if n != 1 else ''})")
100
+ else:
101
+ console.print("[yellow] Auth disabled (--no-auth)[/yellow]")
102
+
103
+ console.print(f" CLI: [dim]browser-cli --remote {host}:{port} tabs list[/dim]")
104
+ console.print(f" Python: [dim]BrowserCLI(remote=\"{host}:{port}\").tabs.list()[/dim]")
105
+ _print_encoding_status(compress)
106
+ console.print("Ctrl-C to stop.\n")
107
+
108
+ def _print_encoding_status(compress: bool) -> None:
109
+ if not compress:
110
+ console.print(" Encode: [yellow]off (--no-compress)[/yellow]")
111
+ return
112
+ codecs = "+".join(transport.supported_compression())
113
+ sers = "+".join(transport.supported_serialization())
114
+ console.print(
115
+ " Encode: [green]on[/green] "
116
+ f"[dim](compression: {codecs}; serialization: {sers}; per-client negotiated)[/dim]"
117
+ )
@@ -0,0 +1,115 @@
1
+ from __future__ import annotations
2
+
3
+ import json
4
+ import secrets
5
+ from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
6
+ from urllib.parse import urlparse
7
+
8
+ import click
9
+ from rich.console import Console
10
+
11
+ from browser_cli import BrowserCLI
12
+ from browser_cli.command_security import CommandPolicy, assert_command_allowed
13
+
14
+ console = Console()
15
+
16
+ def _is_loopback(host: str) -> bool:
17
+ return host in {"127.0.0.1", "localhost", "::1"}
18
+
19
+ class _Handler(BaseHTTPRequestHandler):
20
+ client: BrowserCLI
21
+ token: str | None = None
22
+ policy: CommandPolicy = CommandPolicy()
23
+
24
+ def _authorized(self) -> bool:
25
+ if self.token is None:
26
+ return True
27
+ if self.headers.get("Authorization", "") == f"Bearer {self.token}":
28
+ return True
29
+ return self.headers.get("X-Browser-CLI-Token") == self.token
30
+
31
+ def _require_auth(self) -> bool:
32
+ if self._authorized():
33
+ return True
34
+ self._send(401, {"error": "missing or invalid token"})
35
+ return False
36
+
37
+ def _send(self, status: int, payload):
38
+ raw = json.dumps(payload, default=str).encode("utf-8")
39
+ self.send_response(status)
40
+ self.send_header("Content-Type", "application/json")
41
+ self.send_header("Content-Length", str(len(raw)))
42
+ self.end_headers()
43
+ self.wfile.write(raw)
44
+
45
+ def do_GET(self):
46
+ path = urlparse(self.path).path
47
+ try:
48
+ if path != "/health" and not self._require_auth():
49
+ return
50
+ if path == "/tabs":
51
+ self._send(200, [t.__dict__ for t in self.client.tabs.list()])
52
+ elif path == "/clients":
53
+ self._send(200, self.client.clients())
54
+ elif path == "/health":
55
+ self._send(200, {"ok": True})
56
+ else:
57
+ self._send(404, {"error": "not found"})
58
+ except Exception as exc:
59
+ self._send(500, {"error": str(exc)})
60
+
61
+ def do_POST(self):
62
+ path = urlparse(self.path).path
63
+ try:
64
+ length = int(self.headers.get("Content-Length", "0"))
65
+ body = json.loads(self.rfile.read(length) or b"{}")
66
+ if path == "/command":
67
+ if not self._require_auth():
68
+ return
69
+ command = body.get("command")
70
+ assert_command_allowed(command, self.policy)
71
+ self._send(200, {"result": self.client.command(command, body.get("args") or {})})
72
+ else:
73
+ self._send(404, {"error": "not found"})
74
+ except PermissionError as exc:
75
+ self._send(403, {"error": str(exc)})
76
+ except Exception as exc:
77
+ self._send(500, {"error": str(exc)})
78
+
79
+ def log_message(self, fmt, *args):
80
+ console.print(f"[dim]http[/dim] {self.address_string()} {fmt % args}")
81
+
82
+ @click.command("serve-http")
83
+ @click.option("--host", default="127.0.0.1", show_default=True)
84
+ @click.option("--port", type=int, default=8766, show_default=True)
85
+ @click.option("--browser", default=None, help="Browser alias to target")
86
+ @click.option("--remote", default=None, help="Remote endpoint to target")
87
+ @click.option("--key", default=None, help="Remote auth key spec")
88
+ @click.option("--token", default=None, help="Bearer token required for HTTP access (generated by default)")
89
+ @click.option("--no-auth", is_flag=True, help="Disable HTTP auth (only allowed on loopback hosts)")
90
+ @click.option("--allow-read-page", is_flag=True, help="Allow /command to run page-content read commands")
91
+ @click.option("--allow-control", is_flag=True, help="Allow /command to run browser-control commands")
92
+ @click.option("--allow-dangerous", is_flag=True, help="Allow /command to run high-risk commands")
93
+ def cmd_serve_http(host, port, browser, remote, key, token, no_auth, allow_read_page, allow_control, allow_dangerous):
94
+ """Expose a tiny local HTTP JSON gateway (/tabs, /clients, /command).
95
+
96
+ Auth is enabled by default. Pass the printed token as either
97
+ ``Authorization: Bearer <token>`` or ``X-Browser-CLI-Token: <token>``.
98
+ """
99
+ if no_auth and not _is_loopback(host):
100
+ raise click.ClickException("--no-auth is only allowed on loopback hosts")
101
+ auth_token = None if no_auth else (token or secrets.token_urlsafe(32))
102
+ policy = CommandPolicy(allow_read_page=allow_read_page, allow_control=allow_control, allow_dangerous=allow_dangerous)
103
+ handler = type(
104
+ "BrowserCLIHTTPHandler",
105
+ (_Handler,),
106
+ {"client": BrowserCLI(browser=browser, remote=remote, key=key), "token": auth_token, "policy": policy},
107
+ )
108
+ server = ThreadingHTTPServer((host, port), handler)
109
+ console.print(f"[green]HTTP gateway listening on http://{host}:{port}[/green]")
110
+ if auth_token:
111
+ console.print(f"[yellow]Token:[/yellow] {auth_token}")
112
+ try:
113
+ server.serve_forever()
114
+ except KeyboardInterrupt:
115
+ console.print("\n[yellow]Stopping HTTP gateway[/yellow]")