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,163 @@
|
|
|
1
|
+
import json
|
|
2
|
+
from pathlib import Path
|
|
3
|
+
import click
|
|
4
|
+
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
@click.group("session")
|
|
10
|
+
def session_group():
|
|
11
|
+
"""Save and restore browser sessions."""
|
|
12
|
+
|
|
13
|
+
@session_group.command("save")
|
|
14
|
+
@click.argument("name")
|
|
15
|
+
@handle_errors
|
|
16
|
+
def session_save(name):
|
|
17
|
+
"""Save all current tabs as session NAME."""
|
|
18
|
+
result = client_from_ctx().session.save(name)
|
|
19
|
+
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
|
20
|
+
console.print(f"[green]Session '{name}' saved[/green] ({count} tabs)")
|
|
21
|
+
|
|
22
|
+
@session_group.command("load")
|
|
23
|
+
@click.argument("name")
|
|
24
|
+
@gentle_mode_option("Throttle mode for large restores.")
|
|
25
|
+
@click.option("--discard-background-tabs", is_flag=True, help="Discard restored background tabs after opening to reduce load.")
|
|
26
|
+
@click.option("--lazy", is_flag=True, help="Create lightweight placeholder tabs after --eager-tabs; placeholders load when selected.")
|
|
27
|
+
@click.option("--eager-tabs", type=int, default=10, show_default=True, help="Number of real tabs to open before lazy placeholders.")
|
|
28
|
+
@click.option("--background", "background_job", is_flag=True, help="Start restore as a background job and return immediately.")
|
|
29
|
+
@handle_errors
|
|
30
|
+
def session_load(name, gentle_mode, discard_background_tabs, lazy, eager_tabs, background_job):
|
|
31
|
+
"""Restore session NAME (opens all saved tabs)."""
|
|
32
|
+
b = client_from_ctx()
|
|
33
|
+
if background_job:
|
|
34
|
+
result = b.session.load_background(
|
|
35
|
+
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
|
|
36
|
+
lazy=lazy, eager_tabs=eager_tabs,
|
|
37
|
+
)
|
|
38
|
+
if isinstance(result, dict) and result.get("jobId"):
|
|
39
|
+
console.print(f"[green]Session restore started[/green] job={result['jobId']}")
|
|
40
|
+
return
|
|
41
|
+
else:
|
|
42
|
+
result = b.session.load(
|
|
43
|
+
name, gentle_mode=gentle_mode, discard_background_tabs=discard_background_tabs,
|
|
44
|
+
lazy=lazy, eager_tabs=eager_tabs,
|
|
45
|
+
)
|
|
46
|
+
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
|
47
|
+
console.print(f"[green]Session '{name}' loaded[/green] ({count} tabs opened)")
|
|
48
|
+
|
|
49
|
+
@session_group.command("export")
|
|
50
|
+
@click.argument("name", required=False)
|
|
51
|
+
@click.option("-o", "output", type=click.Path(dir_okay=False, path_type=Path), default=None, help="Write JSON to file instead of stdout")
|
|
52
|
+
@handle_errors
|
|
53
|
+
def session_export(name, output):
|
|
54
|
+
"""Export one saved session, or all sessions as JSON."""
|
|
55
|
+
data = client_from_ctx().session.export(name)
|
|
56
|
+
text = json.dumps(data, indent=2, sort_keys=True)
|
|
57
|
+
if output:
|
|
58
|
+
output.write_text(text + "\n", encoding="utf-8")
|
|
59
|
+
console.print(f"[green]Exported session data to {output}[/green]")
|
|
60
|
+
else:
|
|
61
|
+
click.echo(text)
|
|
62
|
+
|
|
63
|
+
@session_group.command("import")
|
|
64
|
+
@click.argument("name")
|
|
65
|
+
@click.argument("file", type=click.Path(exists=True, dir_okay=False, path_type=Path))
|
|
66
|
+
@click.option("--overwrite", is_flag=True, help="Replace an existing saved session")
|
|
67
|
+
@handle_errors
|
|
68
|
+
def session_import(name, file, overwrite):
|
|
69
|
+
"""Import a saved session JSON file."""
|
|
70
|
+
payload = json.loads(file.read_text(encoding="utf-8"))
|
|
71
|
+
session = payload.get("session", payload) if isinstance(payload, dict) else payload
|
|
72
|
+
result = client_from_ctx().session.import_(name, session, overwrite=overwrite)
|
|
73
|
+
count = result.get("tabs", 0) if isinstance(result, dict) else 0
|
|
74
|
+
console.print(f"[green]Imported session '{name}'[/green] ({count} tabs)")
|
|
75
|
+
|
|
76
|
+
@session_group.command("diff")
|
|
77
|
+
@click.argument("name_a")
|
|
78
|
+
@click.argument("name_b")
|
|
79
|
+
@handle_errors
|
|
80
|
+
def session_diff(name_a, name_b):
|
|
81
|
+
"""Show tabs added/removed between two saved sessions."""
|
|
82
|
+
diff = client_from_ctx().session.diff(name_a, name_b)
|
|
83
|
+
if not diff:
|
|
84
|
+
console.print("[yellow]No diff data returned[/yellow]")
|
|
85
|
+
return
|
|
86
|
+
|
|
87
|
+
added = diff.get("added") or []
|
|
88
|
+
removed = diff.get("removed") or []
|
|
89
|
+
|
|
90
|
+
if added:
|
|
91
|
+
console.print(f"[green]Added in '{name_b}':[/green]")
|
|
92
|
+
for url in added:
|
|
93
|
+
console.print(f" + {url}")
|
|
94
|
+
|
|
95
|
+
if removed:
|
|
96
|
+
console.print(f"[red]Removed in '{name_b}':[/red]")
|
|
97
|
+
for url in removed:
|
|
98
|
+
console.print(f" - {url}")
|
|
99
|
+
|
|
100
|
+
if not added and not removed:
|
|
101
|
+
console.print("[green]Sessions are identical[/green]")
|
|
102
|
+
|
|
103
|
+
@session_group.command("list")
|
|
104
|
+
@handle_errors
|
|
105
|
+
def session_list():
|
|
106
|
+
"""List all saved sessions."""
|
|
107
|
+
from datetime import datetime
|
|
108
|
+
from rich.table import Table
|
|
109
|
+
sessions = client_from_ctx().session.list()
|
|
110
|
+
if not sessions:
|
|
111
|
+
console.print("[yellow]No saved sessions[/yellow]")
|
|
112
|
+
return
|
|
113
|
+
show_browser = any("browser" in s for s in sessions)
|
|
114
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
115
|
+
if show_browser:
|
|
116
|
+
table.add_column("Browser")
|
|
117
|
+
table.add_column("Name")
|
|
118
|
+
table.add_column("Tabs", width=6)
|
|
119
|
+
table.add_column("Saved at")
|
|
120
|
+
for s in sessions:
|
|
121
|
+
saved = datetime.fromtimestamp(s["savedAt"] / 1000).strftime("%Y-%m-%d %H:%M") if s.get("savedAt") else ""
|
|
122
|
+
row = [s.get("browser", "")] if show_browser else []
|
|
123
|
+
row.extend([s["name"], str(s["tabs"]), saved])
|
|
124
|
+
table.add_row(*row)
|
|
125
|
+
console.print(table)
|
|
126
|
+
|
|
127
|
+
@session_group.command("remove")
|
|
128
|
+
@click.argument("name")
|
|
129
|
+
@handle_errors
|
|
130
|
+
def session_remove(name):
|
|
131
|
+
"""Delete a saved session."""
|
|
132
|
+
client_from_ctx().session.remove(name)
|
|
133
|
+
console.print(f"[green]Session '{name}' removed[/green]")
|
|
134
|
+
|
|
135
|
+
@session_group.command("job-status")
|
|
136
|
+
@click.argument("job_id")
|
|
137
|
+
@handle_errors
|
|
138
|
+
def session_job_status(job_id):
|
|
139
|
+
"""Show status for a background session job."""
|
|
140
|
+
result = client_from_ctx().perf.job_status(job_id)
|
|
141
|
+
status = result.get("status", "unknown")
|
|
142
|
+
console.print(f"[bold]{job_id}[/bold]: {status}")
|
|
143
|
+
if result.get("error"):
|
|
144
|
+
console.print(f"[red]{result['error']}[/red]")
|
|
145
|
+
elif result.get("result"):
|
|
146
|
+
console.print(result["result"])
|
|
147
|
+
|
|
148
|
+
@session_group.command("job-cancel")
|
|
149
|
+
@click.argument("job_id")
|
|
150
|
+
@handle_errors
|
|
151
|
+
def session_job_cancel(job_id):
|
|
152
|
+
"""Cancel a running background job."""
|
|
153
|
+
client_from_ctx().perf.job_cancel(job_id)
|
|
154
|
+
console.print(f"[green]Cancel requested for {job_id}[/green]")
|
|
155
|
+
|
|
156
|
+
@session_group.command("auto-save")
|
|
157
|
+
@click.argument("state", type=click.Choice(["on", "off"]))
|
|
158
|
+
@handle_errors
|
|
159
|
+
def session_auto_save(state):
|
|
160
|
+
"""Enable or disable automatic session saving."""
|
|
161
|
+
enabled = state == "on"
|
|
162
|
+
client_from_ctx().session.auto_save(enabled)
|
|
163
|
+
console.print(f"[green]Auto-save {state}[/green]")
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import json
|
|
2
|
+
import click
|
|
3
|
+
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
|
|
4
|
+
from rich.console import Console
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
@click.group("storage")
|
|
9
|
+
def storage_group():
|
|
10
|
+
"""Read and write the page's localStorage / sessionStorage."""
|
|
11
|
+
|
|
12
|
+
@storage_group.command("get")
|
|
13
|
+
@click.argument("key", required=False)
|
|
14
|
+
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
|
15
|
+
@tab_option
|
|
16
|
+
@handle_errors
|
|
17
|
+
def storage_get(key, store_type, tab_id):
|
|
18
|
+
"""Get a localStorage KEY (or dump all keys if omitted)."""
|
|
19
|
+
result = client_from_ctx().storage.get(key, type=store_type, tab_id=tab_id)
|
|
20
|
+
if result is None:
|
|
21
|
+
console.print("[dim]null[/dim]")
|
|
22
|
+
elif isinstance(result, dict):
|
|
23
|
+
console.print(json.dumps(result, indent=2))
|
|
24
|
+
else:
|
|
25
|
+
console.print(str(result))
|
|
26
|
+
|
|
27
|
+
@storage_group.command("set")
|
|
28
|
+
@click.argument("key")
|
|
29
|
+
@click.argument("value")
|
|
30
|
+
@click.option("--type", "store_type", type=click.Choice(["local", "session"]), default="local", show_default=True)
|
|
31
|
+
@tab_option
|
|
32
|
+
@handle_errors
|
|
33
|
+
def storage_set(key, value, store_type, tab_id):
|
|
34
|
+
"""Set localStorage KEY to VALUE."""
|
|
35
|
+
client_from_ctx().storage.set(key, value, type=store_type, tab_id=tab_id)
|
|
36
|
+
console.print(f"[green]Set[/green] {store_type}[{key!r}] = {value!r}")
|
|
@@ -0,0 +1,252 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import binascii
|
|
3
|
+
import click
|
|
4
|
+
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts, tab_option
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
from rich.tree import Tree
|
|
8
|
+
|
|
9
|
+
console = Console()
|
|
10
|
+
|
|
11
|
+
def _print_tabs(tabs, *, show_browser: bool = False) -> None:
|
|
12
|
+
if not tabs:
|
|
13
|
+
console.print("[yellow]No tabs found[/yellow]")
|
|
14
|
+
return
|
|
15
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
16
|
+
if show_browser:
|
|
17
|
+
table.add_column("Browser", no_wrap=True)
|
|
18
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
19
|
+
table.add_column("Window", no_wrap=True)
|
|
20
|
+
table.add_column("Active", width=7)
|
|
21
|
+
table.add_column("Muted", width=7)
|
|
22
|
+
table.add_column("Title")
|
|
23
|
+
table.add_column("URL")
|
|
24
|
+
for t in tabs:
|
|
25
|
+
active = "[green]✓[/green]" if t.active else ""
|
|
26
|
+
muted = "[yellow]✓[/yellow]" if t.muted else ""
|
|
27
|
+
row = [
|
|
28
|
+
(t.browser or "") if show_browser else None,
|
|
29
|
+
str(t.id),
|
|
30
|
+
str(t.window_id),
|
|
31
|
+
active,
|
|
32
|
+
muted,
|
|
33
|
+
(t.title or "")[:60],
|
|
34
|
+
(t.url or "")[:80],
|
|
35
|
+
]
|
|
36
|
+
table.add_row(*[value for value in row if value is not None])
|
|
37
|
+
console.print(table)
|
|
38
|
+
|
|
39
|
+
@click.group("tabs")
|
|
40
|
+
def tabs_group():
|
|
41
|
+
"""Manage browser tabs."""
|
|
42
|
+
|
|
43
|
+
@tabs_group.command("list")
|
|
44
|
+
@handle_errors
|
|
45
|
+
def tabs_list():
|
|
46
|
+
"""List all open tabs across all windows."""
|
|
47
|
+
tabs = client_from_ctx().tabs.list()
|
|
48
|
+
_print_tabs(tabs, show_browser=any(t.browser for t in tabs))
|
|
49
|
+
|
|
50
|
+
@tabs_group.command("tree")
|
|
51
|
+
@handle_errors
|
|
52
|
+
def tabs_tree():
|
|
53
|
+
"""Show tabs grouped as a window/group tree."""
|
|
54
|
+
tabs = sorted(client_from_ctx().tabs.list(), key=lambda t: ((t.browser or ""), t.window_id, t.group_id if t.group_id is not None else -1, t.index))
|
|
55
|
+
root = Tree("[bold]Tabs[/bold]")
|
|
56
|
+
browsers = {}
|
|
57
|
+
windows = {}
|
|
58
|
+
groups = {}
|
|
59
|
+
show_browser = any(t.browser for t in tabs)
|
|
60
|
+
for tab in tabs:
|
|
61
|
+
browser_key = tab.browser or "local"
|
|
62
|
+
browser_node = browsers.setdefault(browser_key, root.add(f"[bold cyan]{browser_key}[/bold cyan]") if show_browser else root)
|
|
63
|
+
win_key = (browser_key, tab.window_id)
|
|
64
|
+
win_node = windows.get(win_key)
|
|
65
|
+
if win_node is None:
|
|
66
|
+
win_node = browser_node.add(f"Window {tab.window_id}")
|
|
67
|
+
windows[win_key] = win_node
|
|
68
|
+
group_label = f"Group {tab.group_id}" if tab.group_id is not None else "Ungrouped"
|
|
69
|
+
group_key = (browser_key, tab.window_id, group_label)
|
|
70
|
+
group_node = groups.get(group_key)
|
|
71
|
+
if group_node is None:
|
|
72
|
+
group_node = win_node.add(group_label)
|
|
73
|
+
groups[group_key] = group_node
|
|
74
|
+
active = " [green]*[/green]" if tab.active else ""
|
|
75
|
+
group_node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]")
|
|
76
|
+
console.print(root)
|
|
77
|
+
|
|
78
|
+
@tabs_group.command("close")
|
|
79
|
+
@click.argument("tab_id", type=int, required=False)
|
|
80
|
+
@click.option("--inactive", is_flag=True, help="Close all inactive tabs")
|
|
81
|
+
@click.option("--duplicates", is_flag=True, help="Close duplicate tabs (keep first)")
|
|
82
|
+
@gentle_mode_option("Throttle mode for large close operations.")
|
|
83
|
+
@handle_errors
|
|
84
|
+
def tabs_close(tab_id, inactive, duplicates, gentle_mode):
|
|
85
|
+
"""Close a tab, all inactive tabs, or all duplicate tabs."""
|
|
86
|
+
count = client_from_ctx().tabs.close(tab_id, inactive=inactive, duplicates=duplicates, gentle_mode=gentle_mode)
|
|
87
|
+
console.print(f"[green]Closed {count} tab(s)[/green]")
|
|
88
|
+
|
|
89
|
+
@tabs_group.command("move")
|
|
90
|
+
@click.argument("tab_id", type=int)
|
|
91
|
+
@click.option("-f", "--forward", "forward", is_flag=True, help="Move one position to the right")
|
|
92
|
+
@click.option("-b", "--backward", "backward", is_flag=True, help="Move one position to the left")
|
|
93
|
+
@click.option("-r", "--right", "forward", is_flag=True, help="Move one position to the right")
|
|
94
|
+
@click.option("-l", "--left", "backward", is_flag=True, help="Move one position to the left")
|
|
95
|
+
@click.option("--group", "group_id", type=int, default=None, help="Move to tab group ID")
|
|
96
|
+
@click.option("--window", "window_id", type=int, default=None, help="Move to window ID")
|
|
97
|
+
@click.option("--index", type=int, default=None, help="Absolute position index in target")
|
|
98
|
+
@handle_errors
|
|
99
|
+
def tabs_move(tab_id, forward, backward, group_id, window_id, index):
|
|
100
|
+
"""Move a tab. Use --forward/--backward or --right/--left for relative movement."""
|
|
101
|
+
client_from_ctx().tabs.move(
|
|
102
|
+
tab_id, forward=forward, backward=backward,
|
|
103
|
+
group_id=group_id, window_id=window_id, index=index,
|
|
104
|
+
)
|
|
105
|
+
console.print("[green]Tab moved[/green]")
|
|
106
|
+
|
|
107
|
+
@tabs_group.command("active")
|
|
108
|
+
@click.argument("tab_id", type=int)
|
|
109
|
+
@handle_errors
|
|
110
|
+
def tabs_active(tab_id):
|
|
111
|
+
"""Switch browser focus to a tab."""
|
|
112
|
+
client_from_ctx().tabs.activate(tab_id)
|
|
113
|
+
console.print(f"[green]Switched to tab {tab_id}[/green]")
|
|
114
|
+
|
|
115
|
+
@tabs_group.command("status")
|
|
116
|
+
@click.argument("tab_id", type=int, required=False)
|
|
117
|
+
@handle_errors
|
|
118
|
+
def tabs_status(tab_id):
|
|
119
|
+
"""Show status for the active tab or a specific tab."""
|
|
120
|
+
tab = client_from_ctx().tabs.status(tab_id)
|
|
121
|
+
table = Table(show_header=False)
|
|
122
|
+
table.add_column("Field", style="bold cyan")
|
|
123
|
+
table.add_column("Value")
|
|
124
|
+
table.add_row("ID", str(tab.id))
|
|
125
|
+
table.add_row("Window", str(tab.window_id))
|
|
126
|
+
table.add_row("Active", "yes" if tab.active else "no")
|
|
127
|
+
table.add_row("Muted", "yes" if tab.muted else "no")
|
|
128
|
+
table.add_row("Title", tab.title or "")
|
|
129
|
+
table.add_row("URL", tab.url or "")
|
|
130
|
+
console.print(table)
|
|
131
|
+
|
|
132
|
+
@tabs_group.command("filter")
|
|
133
|
+
@click.argument("pattern")
|
|
134
|
+
@handle_errors
|
|
135
|
+
def tabs_filter(pattern):
|
|
136
|
+
"""List tabs whose URL contains PATTERN."""
|
|
137
|
+
_print_tabs(client_from_ctx().tabs.filter(pattern))
|
|
138
|
+
|
|
139
|
+
@tabs_group.command("count")
|
|
140
|
+
@click.argument("pattern", required=False)
|
|
141
|
+
@handle_errors
|
|
142
|
+
def tabs_count(pattern):
|
|
143
|
+
"""Count open tabs, optionally filtered by URL PATTERN."""
|
|
144
|
+
label = f" matching '{pattern}'" if pattern else ""
|
|
145
|
+
print_counts(client_from_ctx().tabs.count(pattern), "tab", single_suffix=label)
|
|
146
|
+
|
|
147
|
+
@tabs_group.command("query")
|
|
148
|
+
@click.argument("search")
|
|
149
|
+
@handle_errors
|
|
150
|
+
def tabs_query(search):
|
|
151
|
+
"""Search tabs by URL or title."""
|
|
152
|
+
_print_tabs(client_from_ctx().tabs.query(search))
|
|
153
|
+
|
|
154
|
+
@tabs_group.command("html")
|
|
155
|
+
@click.argument("tab_id", type=int, required=False)
|
|
156
|
+
@handle_errors
|
|
157
|
+
def tabs_html(tab_id):
|
|
158
|
+
"""Print the full HTML of a tab."""
|
|
159
|
+
console.print(client_from_ctx().tabs.html(tab_id))
|
|
160
|
+
|
|
161
|
+
@tabs_group.command("dedupe")
|
|
162
|
+
@gentle_mode_option("Throttle mode for large dedupe operations.")
|
|
163
|
+
@handle_errors
|
|
164
|
+
def tabs_dedupe(gentle_mode):
|
|
165
|
+
"""Close duplicate tabs (keep the first occurrence of each URL)."""
|
|
166
|
+
count = client_from_ctx().tabs.dedupe(gentle_mode=gentle_mode)
|
|
167
|
+
console.print(f"[green]Closed {count} duplicate tab(s)[/green]")
|
|
168
|
+
|
|
169
|
+
@tabs_group.command("sort")
|
|
170
|
+
@click.option("--by", type=click.Choice(["domain", "title", "time"]), default="domain", show_default=True)
|
|
171
|
+
@gentle_mode_option("Throttle mode for large sort operations.")
|
|
172
|
+
@handle_errors
|
|
173
|
+
def tabs_sort(by, gentle_mode):
|
|
174
|
+
"""Sort tabs within each window."""
|
|
175
|
+
client_from_ctx().tabs.sort(by=by, gentle_mode=gentle_mode)
|
|
176
|
+
console.print(f"[green]Tabs sorted by {by}[/green]")
|
|
177
|
+
|
|
178
|
+
@tabs_group.command("merge-windows")
|
|
179
|
+
@gentle_mode_option("Throttle mode for large merge operations.")
|
|
180
|
+
@handle_errors
|
|
181
|
+
def tabs_merge_windows(gentle_mode):
|
|
182
|
+
"""Move all tabs into the focused window."""
|
|
183
|
+
count = client_from_ctx().tabs.merge_windows(gentle_mode=gentle_mode)
|
|
184
|
+
console.print(f"[green]Merged — moved {count} tab(s) into current window[/green]")
|
|
185
|
+
|
|
186
|
+
@tabs_group.command("mute")
|
|
187
|
+
@click.argument("tab_id", type=int, required=False)
|
|
188
|
+
@handle_errors
|
|
189
|
+
def tabs_mute(tab_id):
|
|
190
|
+
"""Mute the active tab or a specific tab."""
|
|
191
|
+
target = client_from_ctx().tabs.mute(tab_id)
|
|
192
|
+
console.print(f"[green]Muted tab {target}[/green]")
|
|
193
|
+
|
|
194
|
+
@tabs_group.command("unmute")
|
|
195
|
+
@click.argument("tab_id", type=int, required=False)
|
|
196
|
+
@handle_errors
|
|
197
|
+
def tabs_unmute(tab_id):
|
|
198
|
+
"""Unmute the active tab or a specific tab."""
|
|
199
|
+
target = client_from_ctx().tabs.unmute(tab_id)
|
|
200
|
+
console.print(f"[green]Unmuted tab {target}[/green]")
|
|
201
|
+
|
|
202
|
+
@tabs_group.command("pin")
|
|
203
|
+
@click.argument("tab_id", type=int, required=False)
|
|
204
|
+
@handle_errors
|
|
205
|
+
def tabs_pin(tab_id):
|
|
206
|
+
"""Pin the active tab or a specific tab."""
|
|
207
|
+
target = client_from_ctx().tabs.pin(tab_id)
|
|
208
|
+
console.print(f"[green]Pinned tab {target}[/green]")
|
|
209
|
+
|
|
210
|
+
@tabs_group.command("unpin")
|
|
211
|
+
@click.argument("tab_id", type=int, required=False)
|
|
212
|
+
@handle_errors
|
|
213
|
+
def tabs_unpin(tab_id):
|
|
214
|
+
"""Unpin the active tab or a specific tab."""
|
|
215
|
+
target = client_from_ctx().tabs.unpin(tab_id)
|
|
216
|
+
console.print(f"[green]Unpinned tab {target}[/green]")
|
|
217
|
+
|
|
218
|
+
@tabs_group.command("watch-url")
|
|
219
|
+
@click.argument("pattern")
|
|
220
|
+
@tab_option
|
|
221
|
+
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
|
222
|
+
@handle_errors
|
|
223
|
+
def tabs_watch_url(pattern, tab_id, timeout):
|
|
224
|
+
"""Wait until the active (or specified) tab URL matches regex PATTERN."""
|
|
225
|
+
tab = client_from_ctx().tabs.watch_url(pattern, tab_id=tab_id, timeout=timeout)
|
|
226
|
+
console.print(f"[green]URL matched:[/green] {tab.url}")
|
|
227
|
+
|
|
228
|
+
@tabs_group.command("screenshot")
|
|
229
|
+
@click.argument("output", required=False, metavar="FILE")
|
|
230
|
+
@tab_option
|
|
231
|
+
@click.option("--format", "fmt", type=click.Choice(["png", "jpeg"]), default="png", show_default=True)
|
|
232
|
+
@click.option("--quality", type=int, default=None, help="JPEG quality 0-100")
|
|
233
|
+
@handle_errors
|
|
234
|
+
def tabs_screenshot(output, tab_id, fmt, quality):
|
|
235
|
+
"""Capture a screenshot of the active (or specified) tab.
|
|
236
|
+
|
|
237
|
+
Saves to FILE if given, otherwise prints the base64 data URL.
|
|
238
|
+
"""
|
|
239
|
+
data_url = client_from_ctx().tabs.screenshot(tab_id, format=fmt, quality=quality)
|
|
240
|
+
if output:
|
|
241
|
+
header = f"data:image/{fmt};base64,"
|
|
242
|
+
if not data_url.startswith(header):
|
|
243
|
+
raise click.ClickException("Empty or unexpected screenshot response (incognito/protected tab?)")
|
|
244
|
+
try:
|
|
245
|
+
raw = base64.b64decode(data_url[len(header):])
|
|
246
|
+
except binascii.Error as e:
|
|
247
|
+
raise click.ClickException(f"Failed to decode screenshot data: {e}")
|
|
248
|
+
with open(output, "wb") as f:
|
|
249
|
+
f.write(raw)
|
|
250
|
+
console.print(f"[green]Screenshot saved:[/green] {output}")
|
|
251
|
+
else:
|
|
252
|
+
console.print(data_url)
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
|
|
6
|
+
import click
|
|
7
|
+
|
|
8
|
+
from browser_cli.commands import client_from_ctx, handle_errors
|
|
9
|
+
|
|
10
|
+
@click.group("watch")
|
|
11
|
+
def watch_group():
|
|
12
|
+
"""Watch browser state and print changes."""
|
|
13
|
+
|
|
14
|
+
@watch_group.command("tabs")
|
|
15
|
+
@click.option("--interval", type=float, default=1.0, show_default=True)
|
|
16
|
+
@click.option("--once", is_flag=True)
|
|
17
|
+
@handle_errors
|
|
18
|
+
def watch_tabs(interval, once):
|
|
19
|
+
"""Watch the tab list as JSON snapshots."""
|
|
20
|
+
client = client_from_ctx()
|
|
21
|
+
previous = None
|
|
22
|
+
while True:
|
|
23
|
+
current = [t.__dict__ for t in client.tabs.list()]
|
|
24
|
+
if current != previous:
|
|
25
|
+
click.echo(json.dumps({"type": "tabs", "tabs": current}, default=str), flush=True)
|
|
26
|
+
previous = current
|
|
27
|
+
if once:
|
|
28
|
+
return
|
|
29
|
+
time.sleep(interval)
|
|
30
|
+
|
|
31
|
+
@watch_group.command("page")
|
|
32
|
+
@click.option("--field", default=None, help="Only print a single page.info field")
|
|
33
|
+
@click.option("--interval", type=float, default=1.0, show_default=True)
|
|
34
|
+
@handle_errors
|
|
35
|
+
def watch_page(field, interval):
|
|
36
|
+
"""Watch page.info for the active tab."""
|
|
37
|
+
client = client_from_ctx()
|
|
38
|
+
previous = object()
|
|
39
|
+
while True:
|
|
40
|
+
info = client.page.info()
|
|
41
|
+
current = info.get(field) if field else info
|
|
42
|
+
if current != previous:
|
|
43
|
+
click.echo(json.dumps({"type": "page", "field": field, "value": current}, default=str), flush=True)
|
|
44
|
+
previous = current
|
|
45
|
+
time.sleep(interval)
|
|
46
|
+
|
|
47
|
+
@watch_group.command("dom")
|
|
48
|
+
@click.argument("selector")
|
|
49
|
+
@click.option("--interval", type=float, default=1.0, show_default=True)
|
|
50
|
+
@handle_errors
|
|
51
|
+
def watch_dom(selector, interval):
|
|
52
|
+
"""Watch textContent for a selector."""
|
|
53
|
+
client = client_from_ctx()
|
|
54
|
+
previous = object()
|
|
55
|
+
while True:
|
|
56
|
+
current = client.dom.text(selector)
|
|
57
|
+
if current != previous:
|
|
58
|
+
click.echo(json.dumps({"type": "dom", "selector": selector, "text": current}, default=str), flush=True)
|
|
59
|
+
previous = current
|
|
60
|
+
time.sleep(interval)
|
|
@@ -0,0 +1,87 @@
|
|
|
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
|
+
from rich.tree import Tree
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
def _print_windows(windows: list[dict], *, show_browser: bool = False) -> None:
|
|
10
|
+
if not windows:
|
|
11
|
+
console.print("[yellow]No windows found[/yellow]")
|
|
12
|
+
return
|
|
13
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
14
|
+
if show_browser:
|
|
15
|
+
table.add_column("Browser")
|
|
16
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
17
|
+
table.add_column("Alias", width=20)
|
|
18
|
+
table.add_column("Tabs", width=6)
|
|
19
|
+
table.add_column("State", width=12)
|
|
20
|
+
for w in windows:
|
|
21
|
+
row = [
|
|
22
|
+
w.get("browser", "") if show_browser else None,
|
|
23
|
+
str(w.get("id", "")),
|
|
24
|
+
w.get("alias") or "",
|
|
25
|
+
str(w.get("tabCount", "")),
|
|
26
|
+
w.get("state") or "",
|
|
27
|
+
]
|
|
28
|
+
table.add_row(*[value for value in row if value is not None])
|
|
29
|
+
console.print(table)
|
|
30
|
+
|
|
31
|
+
@click.group("windows")
|
|
32
|
+
def windows_group():
|
|
33
|
+
"""Manage browser windows."""
|
|
34
|
+
|
|
35
|
+
@windows_group.command("list")
|
|
36
|
+
@handle_errors
|
|
37
|
+
def windows_list():
|
|
38
|
+
"""List all browser windows."""
|
|
39
|
+
windows = client_from_ctx().windows.list()
|
|
40
|
+
_print_windows(windows, show_browser=any("browser" in w for w in windows))
|
|
41
|
+
|
|
42
|
+
@windows_group.command("tree")
|
|
43
|
+
@handle_errors
|
|
44
|
+
def windows_tree():
|
|
45
|
+
"""Show windows and their tabs as a tree."""
|
|
46
|
+
client = client_from_ctx()
|
|
47
|
+
windows = client.windows.list()
|
|
48
|
+
tabs = client.tabs.list()
|
|
49
|
+
root = Tree("[bold]Windows[/bold]")
|
|
50
|
+
for w in sorted(windows, key=lambda item: (item.get("browser", ""), item.get("id", 0))):
|
|
51
|
+
wid = w.get("id")
|
|
52
|
+
label = f"Window {wid}"
|
|
53
|
+
if w.get("alias"):
|
|
54
|
+
label += f" ({w['alias']})"
|
|
55
|
+
if w.get("browser"):
|
|
56
|
+
label = f"{w['browser']}: " + label
|
|
57
|
+
node = root.add(label)
|
|
58
|
+
for tab in sorted([t for t in tabs if t.window_id == wid and (not w.get("browser") or t.browser == w.get("browser"))], key=lambda t: t.index):
|
|
59
|
+
active = " [green]*[/green]" if tab.active else ""
|
|
60
|
+
node.add(f"[{tab.id}] {tab.title or '(untitled)'}{active} — [dim]{tab.url or ''}[/dim]")
|
|
61
|
+
console.print(root)
|
|
62
|
+
|
|
63
|
+
@windows_group.command("rename")
|
|
64
|
+
@click.argument("window_id", type=int)
|
|
65
|
+
@click.argument("name")
|
|
66
|
+
@handle_errors
|
|
67
|
+
def windows_rename(window_id, name):
|
|
68
|
+
"""Give a window a local alias NAME (stored in native host)."""
|
|
69
|
+
client_from_ctx().windows.rename(window_id, name)
|
|
70
|
+
console.print(f"[green]Window {window_id} aliased as '{name}'[/green]")
|
|
71
|
+
|
|
72
|
+
@windows_group.command("close")
|
|
73
|
+
@click.argument("window_id", type=int)
|
|
74
|
+
@handle_errors
|
|
75
|
+
def windows_close(window_id):
|
|
76
|
+
"""Close a browser window."""
|
|
77
|
+
client_from_ctx().windows.close(window_id)
|
|
78
|
+
console.print(f"[green]Window {window_id} closed[/green]")
|
|
79
|
+
|
|
80
|
+
@windows_group.command("open")
|
|
81
|
+
@click.argument("url", required=False)
|
|
82
|
+
@handle_errors
|
|
83
|
+
def windows_open(url):
|
|
84
|
+
"""Open a new browser window."""
|
|
85
|
+
result = client_from_ctx().windows.open(url)
|
|
86
|
+
wid = result.get("id") if isinstance(result, dict) else result
|
|
87
|
+
console.print(f"[green]Opened new window[/green] (id: {wid})" + (f" with {url}" if url else ""))
|