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.
- browser_cli/__init__.py +164 -0
- browser_cli/async_sdk.py +237 -0
- browser_cli/auth.py +263 -0
- browser_cli/cli.py +151 -0
- browser_cli/client/__init__.py +47 -0
- browser_cli/client/auth.py +63 -0
- browser_cli/client/core.py +200 -0
- browser_cli/client/messages.py +45 -0
- browser_cli/client/targets.py +95 -0
- browser_cli/command_security.py +119 -0
- browser_cli/commands/__init__.py +81 -0
- browser_cli/commands/auth.py +157 -0
- browser_cli/commands/clients.py +173 -0
- browser_cli/commands/completion.py +56 -0
- browser_cli/commands/doctor.py +90 -0
- browser_cli/commands/dom.py +191 -0
- browser_cli/commands/events.py +52 -0
- browser_cli/commands/extension.py +42 -0
- browser_cli/commands/extract.py +70 -0
- browser_cli/commands/groups.py +108 -0
- browser_cli/commands/install.py +121 -0
- browser_cli/commands/navigate.py +96 -0
- browser_cli/commands/page.py +26 -0
- browser_cli/commands/perf.py +47 -0
- browser_cli/commands/raw.py +23 -0
- browser_cli/commands/remote.py +68 -0
- browser_cli/commands/script.py +68 -0
- browser_cli/commands/search.py +79 -0
- browser_cli/commands/serve.py +117 -0
- browser_cli/commands/serve_http.py +115 -0
- browser_cli/commands/session.py +163 -0
- browser_cli/commands/storage.py +36 -0
- browser_cli/commands/tabs.py +252 -0
- browser_cli/commands/watch.py +60 -0
- browser_cli/commands/windows.py +87 -0
- browser_cli/commands/workspace.py +91 -0
- browser_cli/compat/__init__.py +4 -0
- browser_cli/compat/auth.py +44 -0
- browser_cli/compat/commands.py +43 -0
- browser_cli/constants.py +95 -0
- browser_cli/endpoints.py +55 -0
- browser_cli/errors.py +9 -0
- browser_cli/framing.py +83 -0
- browser_cli/local_transport.py +64 -0
- browser_cli/markdown/__init__.py +8 -0
- browser_cli/markdown/html.py +259 -0
- browser_cli/markdown/render.py +188 -0
- browser_cli/models.py +182 -0
- browser_cli/native/__init__.py +1 -0
- browser_cli/native/host.py +211 -0
- browser_cli/native/local_server.py +111 -0
- browser_cli/native/protocol.py +30 -0
- browser_cli/platform.py +34 -0
- browser_cli/registry.py +99 -0
- browser_cli/remote/__init__.py +1 -0
- browser_cli/remote/registry.py +53 -0
- browser_cli/remote/transport.py +230 -0
- browser_cli/sdk/__init__.py +48 -0
- browser_cli/sdk/base.py +116 -0
- browser_cli/sdk/browser_data.py +37 -0
- browser_cli/sdk/decorators.py +107 -0
- browser_cli/sdk/dom.py +169 -0
- browser_cli/sdk/extension.py +24 -0
- browser_cli/sdk/factories.py +103 -0
- browser_cli/sdk/groups.py +51 -0
- browser_cli/sdk/navigation.py +122 -0
- browser_cli/sdk/perf.py +23 -0
- browser_cli/sdk/routing.py +149 -0
- browser_cli/sdk/session.py +72 -0
- browser_cli/sdk/tabs.py +213 -0
- browser_cli/sdk/windows.py +26 -0
- browser_cli/sdk/workflow_decorators.py +200 -0
- browser_cli/serve/__init__.py +0 -0
- browser_cli/serve/auth.py +107 -0
- browser_cli/serve/control.py +59 -0
- browser_cli/serve/logging.py +16 -0
- browser_cli/serve/proxy.py +79 -0
- browser_cli/serve/runtime.py +196 -0
- browser_cli/transport.py +214 -0
- browser_cli/version_manager.py +17 -0
- real_browser_cli-0.14.2.dist-info/METADATA +87 -0
- real_browser_cli-0.14.2.dist-info/RECORD +85 -0
- real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
- real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
- 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]")
|