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,191 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from browser_cli.commands import client_from_ctx, handle_errors, tab_option
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
import json
|
|
6
|
+
|
|
7
|
+
console = Console()
|
|
8
|
+
|
|
9
|
+
@click.group("dom")
|
|
10
|
+
def dom_group():
|
|
11
|
+
"""Query and interact with page DOM elements."""
|
|
12
|
+
|
|
13
|
+
@dom_group.command("query")
|
|
14
|
+
@click.argument("selector")
|
|
15
|
+
@handle_errors
|
|
16
|
+
def dom_query(selector):
|
|
17
|
+
"""Return elements matching CSS SELECTOR (like mini DevTools)."""
|
|
18
|
+
elements = client_from_ctx().dom.query(selector)
|
|
19
|
+
if not elements:
|
|
20
|
+
console.print("[yellow]No elements found[/yellow]")
|
|
21
|
+
return
|
|
22
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
23
|
+
table.add_column("Tag", width=12)
|
|
24
|
+
table.add_column("Text", width=40)
|
|
25
|
+
table.add_column("Attributes")
|
|
26
|
+
for el in elements:
|
|
27
|
+
attrs = ", ".join(f"{k}={v!r}" for k, v in (el.get("attrs") or {}).items())
|
|
28
|
+
table.add_row(el.get("tag", ""), (el.get("text") or "")[:60], attrs[:80])
|
|
29
|
+
console.print(table)
|
|
30
|
+
|
|
31
|
+
@dom_group.command("click")
|
|
32
|
+
@click.argument("selector")
|
|
33
|
+
@handle_errors
|
|
34
|
+
def dom_click(selector):
|
|
35
|
+
"""Click the first element matching CSS SELECTOR."""
|
|
36
|
+
client_from_ctx().dom.click(selector)
|
|
37
|
+
console.print(f"[green]Clicked:[/green] {selector}")
|
|
38
|
+
|
|
39
|
+
@dom_group.command("type")
|
|
40
|
+
@click.argument("selector")
|
|
41
|
+
@click.argument("text")
|
|
42
|
+
@handle_errors
|
|
43
|
+
def dom_type(selector, text):
|
|
44
|
+
"""Type TEXT into the element matching CSS SELECTOR."""
|
|
45
|
+
client_from_ctx().dom.type(selector, text)
|
|
46
|
+
console.print(f"[green]Typed into:[/green] {selector}")
|
|
47
|
+
|
|
48
|
+
@dom_group.command("attr")
|
|
49
|
+
@click.argument("selector")
|
|
50
|
+
@click.argument("attr_name")
|
|
51
|
+
@handle_errors
|
|
52
|
+
def dom_attr(selector, attr_name):
|
|
53
|
+
"""Get attribute ATTR_NAME from elements matching CSS SELECTOR."""
|
|
54
|
+
for v in client_from_ctx().dom.attr(selector, attr_name):
|
|
55
|
+
console.print(v)
|
|
56
|
+
|
|
57
|
+
@dom_group.command("text")
|
|
58
|
+
@click.argument("selector")
|
|
59
|
+
@handle_errors
|
|
60
|
+
def dom_text(selector):
|
|
61
|
+
"""Get text content of elements matching CSS SELECTOR."""
|
|
62
|
+
for v in client_from_ctx().dom.text(selector):
|
|
63
|
+
console.print(v)
|
|
64
|
+
|
|
65
|
+
@dom_group.command("exists")
|
|
66
|
+
@click.argument("selector")
|
|
67
|
+
@handle_errors
|
|
68
|
+
def dom_exists(selector):
|
|
69
|
+
"""Check if an element matching CSS SELECTOR exists on the page."""
|
|
70
|
+
if client_from_ctx().dom.exists(selector):
|
|
71
|
+
console.print(f"[green]exists[/green]: {selector}")
|
|
72
|
+
else:
|
|
73
|
+
console.print(f"[red]not found[/red]: {selector}")
|
|
74
|
+
raise SystemExit(1)
|
|
75
|
+
|
|
76
|
+
@dom_group.command("scroll")
|
|
77
|
+
@click.argument("selector", required=False)
|
|
78
|
+
@click.option("--x", type=int, default=None, help="Horizontal scroll position (px)")
|
|
79
|
+
@click.option("--y", type=int, default=None, help="Vertical scroll position (px)")
|
|
80
|
+
@handle_errors
|
|
81
|
+
def dom_scroll(selector, x, y):
|
|
82
|
+
"""Scroll to a CSS SELECTOR or to an X/Y coordinate."""
|
|
83
|
+
client_from_ctx().dom.scroll(selector, x=x, y=y)
|
|
84
|
+
target = selector or f"({x or 0}, {y or 0})"
|
|
85
|
+
console.print(f"[green]Scrolled to:[/green] {target}")
|
|
86
|
+
|
|
87
|
+
@dom_group.command("select")
|
|
88
|
+
@click.argument("selector")
|
|
89
|
+
@click.argument("value")
|
|
90
|
+
@handle_errors
|
|
91
|
+
def dom_select(selector, value):
|
|
92
|
+
"""Set the VALUE of a <select> dropdown matching CSS SELECTOR."""
|
|
93
|
+
client_from_ctx().dom.select(selector, value)
|
|
94
|
+
console.print(f"[green]Selected '{value}' in:[/green] {selector}")
|
|
95
|
+
|
|
96
|
+
@dom_group.command("eval")
|
|
97
|
+
@click.argument("code")
|
|
98
|
+
@tab_option
|
|
99
|
+
@handle_errors
|
|
100
|
+
def dom_eval(code, tab_id):
|
|
101
|
+
"""Evaluate JavaScript CODE in the page and print the result."""
|
|
102
|
+
result = client_from_ctx().dom.eval(code, tab_id)
|
|
103
|
+
if result is None:
|
|
104
|
+
console.print("[dim]null[/dim]")
|
|
105
|
+
else:
|
|
106
|
+
console.print(json.dumps(result, indent=2) if isinstance(result, (dict, list)) else str(result))
|
|
107
|
+
|
|
108
|
+
@dom_group.command("wait-for")
|
|
109
|
+
@click.argument("selector")
|
|
110
|
+
@click.option("--timeout", type=float, default=10.0, show_default=True, help="Max seconds to wait")
|
|
111
|
+
@click.option("--visible", is_flag=True, help="Wait until element is visible (non-zero size)")
|
|
112
|
+
@click.option("--hidden", is_flag=True, help="Wait until element is absent or hidden")
|
|
113
|
+
@tab_option
|
|
114
|
+
@handle_errors
|
|
115
|
+
def dom_wait_for(selector, timeout, visible, hidden, tab_id):
|
|
116
|
+
"""Wait until CSS SELECTOR appears (or disappears) in the DOM."""
|
|
117
|
+
client_from_ctx().dom.wait_for(selector, timeout=timeout, visible=visible, hidden=hidden, tab_id=tab_id)
|
|
118
|
+
state = "hidden" if hidden else ("visible" if visible else "present")
|
|
119
|
+
console.print(f"[green]Ready ({state}):[/green] {selector}")
|
|
120
|
+
|
|
121
|
+
@dom_group.command("key")
|
|
122
|
+
@click.argument("key")
|
|
123
|
+
@click.option("--selector", default=None, help="CSS selector to target (default: focused element)")
|
|
124
|
+
@handle_errors
|
|
125
|
+
def dom_key(key, selector):
|
|
126
|
+
"""Dispatch a keyboard KEY event (e.g. Enter, Tab, Escape, ArrowDown)."""
|
|
127
|
+
client_from_ctx().dom.key(key, selector)
|
|
128
|
+
target = selector or "active element"
|
|
129
|
+
console.print(f"[green]Key '{key}' sent to:[/green] {target}")
|
|
130
|
+
|
|
131
|
+
@dom_group.command("hover")
|
|
132
|
+
@click.argument("selector")
|
|
133
|
+
@handle_errors
|
|
134
|
+
def dom_hover(selector):
|
|
135
|
+
"""Dispatch mouseover/mouseenter on the element matching CSS SELECTOR."""
|
|
136
|
+
client_from_ctx().dom.hover(selector)
|
|
137
|
+
console.print(f"[green]Hovered:[/green] {selector}")
|
|
138
|
+
|
|
139
|
+
@dom_group.command("check")
|
|
140
|
+
@click.argument("selector")
|
|
141
|
+
@handle_errors
|
|
142
|
+
def dom_check(selector):
|
|
143
|
+
"""Check a checkbox matching CSS SELECTOR."""
|
|
144
|
+
client_from_ctx().dom.check(selector)
|
|
145
|
+
console.print(f"[green]Checked:[/green] {selector}")
|
|
146
|
+
|
|
147
|
+
@dom_group.command("uncheck")
|
|
148
|
+
@click.argument("selector")
|
|
149
|
+
@handle_errors
|
|
150
|
+
def dom_uncheck(selector):
|
|
151
|
+
"""Uncheck a checkbox matching CSS SELECTOR."""
|
|
152
|
+
client_from_ctx().dom.uncheck(selector)
|
|
153
|
+
console.print(f"[green]Unchecked:[/green] {selector}")
|
|
154
|
+
|
|
155
|
+
@dom_group.command("clear")
|
|
156
|
+
@click.argument("selector")
|
|
157
|
+
@handle_errors
|
|
158
|
+
def dom_clear(selector):
|
|
159
|
+
"""Clear the value of an input matching CSS SELECTOR."""
|
|
160
|
+
client_from_ctx().dom.clear(selector)
|
|
161
|
+
console.print(f"[green]Cleared:[/green] {selector}")
|
|
162
|
+
|
|
163
|
+
@dom_group.command("focus")
|
|
164
|
+
@click.argument("selector")
|
|
165
|
+
@handle_errors
|
|
166
|
+
def dom_focus(selector):
|
|
167
|
+
"""Focus the element matching CSS SELECTOR."""
|
|
168
|
+
client_from_ctx().dom.focus(selector)
|
|
169
|
+
console.print(f"[green]Focused:[/green] {selector}")
|
|
170
|
+
|
|
171
|
+
@dom_group.command("submit")
|
|
172
|
+
@click.argument("selector")
|
|
173
|
+
@handle_errors
|
|
174
|
+
def dom_submit(selector):
|
|
175
|
+
"""Submit the form that contains the element matching CSS SELECTOR."""
|
|
176
|
+
client_from_ctx().dom.submit(selector)
|
|
177
|
+
console.print(f"[green]Submitted form for:[/green] {selector}")
|
|
178
|
+
|
|
179
|
+
@dom_group.command("poll")
|
|
180
|
+
@click.argument("selector")
|
|
181
|
+
@click.argument("pattern")
|
|
182
|
+
@click.option("--attr", default=None, help="Attribute or property to read (default: textContent/value)")
|
|
183
|
+
@click.option("--timeout", type=float, default=30.0, show_default=True, help="Max seconds to wait")
|
|
184
|
+
@click.option("--interval", type=float, default=0.5, show_default=True, help="Poll interval in seconds")
|
|
185
|
+
@tab_option
|
|
186
|
+
@handle_errors
|
|
187
|
+
def dom_poll(selector, pattern, attr, timeout, interval, tab_id):
|
|
188
|
+
"""Poll SELECTOR until its text/value matches regex PATTERN."""
|
|
189
|
+
result = client_from_ctx().dom.poll(selector, pattern, attr=attr, timeout=timeout, interval=interval, tab_id=tab_id)
|
|
190
|
+
value = result.get("value", "") if isinstance(result, dict) else ""
|
|
191
|
+
console.print(f"[green]Matched:[/green] {selector!r} = {value!r}")
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
from __future__ import annotations
|
|
2
|
+
|
|
3
|
+
import json
|
|
4
|
+
import time
|
|
5
|
+
from dataclasses import asdict, is_dataclass
|
|
6
|
+
|
|
7
|
+
import click
|
|
8
|
+
from rich.console import Console
|
|
9
|
+
|
|
10
|
+
from browser_cli.commands import client_from_ctx, handle_errors
|
|
11
|
+
|
|
12
|
+
console = Console()
|
|
13
|
+
|
|
14
|
+
def _snapshot(client):
|
|
15
|
+
tabs = client.tabs.list()
|
|
16
|
+
return {str(t.id): asdict(t) if is_dataclass(t) else dict(t) for t in tabs}
|
|
17
|
+
|
|
18
|
+
def _emit(event, json_output: bool):
|
|
19
|
+
if json_output:
|
|
20
|
+
click.echo(json.dumps(event, default=str), flush=True)
|
|
21
|
+
else:
|
|
22
|
+
kind = event.get("type")
|
|
23
|
+
tab = event.get("tab") or {}
|
|
24
|
+
console.print(f"[cyan]{kind}[/cyan] {tab.get('id', '')} {tab.get('title') or ''} [dim]{tab.get('url') or ''}[/dim]")
|
|
25
|
+
|
|
26
|
+
@click.command("events")
|
|
27
|
+
@click.option("--interval", type=float, default=1.0, show_default=True, help="Polling interval in seconds")
|
|
28
|
+
@click.option("--once", is_flag=True, help="Emit initial snapshot and exit")
|
|
29
|
+
@click.option("--json", "json_output", is_flag=True, default=True, help="Emit JSON Lines (default)")
|
|
30
|
+
@click.option("--pretty", is_flag=True, help="Render human-readable events instead of JSON")
|
|
31
|
+
@handle_errors
|
|
32
|
+
def cmd_events(interval: float, once: bool, json_output: bool, pretty: bool):
|
|
33
|
+
"""Stream tab events as JSON Lines using a lightweight polling watcher."""
|
|
34
|
+
json_output = json_output and not pretty
|
|
35
|
+
client = client_from_ctx()
|
|
36
|
+
previous = _snapshot(client)
|
|
37
|
+
for tab in previous.values():
|
|
38
|
+
_emit({"type": "tabs.snapshot", "tab": tab}, json_output)
|
|
39
|
+
if once:
|
|
40
|
+
return
|
|
41
|
+
while True:
|
|
42
|
+
time.sleep(interval)
|
|
43
|
+
current = _snapshot(client)
|
|
44
|
+
for tab_id, tab in current.items():
|
|
45
|
+
if tab_id not in previous:
|
|
46
|
+
_emit({"type": "tabs.created", "tab": tab}, json_output)
|
|
47
|
+
elif tab != previous[tab_id]:
|
|
48
|
+
_emit({"type": "tabs.updated", "tab": tab, "previous": previous[tab_id]}, json_output)
|
|
49
|
+
for tab_id, tab in previous.items():
|
|
50
|
+
if tab_id not in current:
|
|
51
|
+
_emit({"type": "tabs.closed", "tab": tab}, json_output)
|
|
52
|
+
previous = current
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from browser_cli.commands import client_from_ctx, handle_errors
|
|
4
|
+
|
|
5
|
+
console = Console()
|
|
6
|
+
|
|
7
|
+
@click.group("extension")
|
|
8
|
+
def extension_group():
|
|
9
|
+
"""Manage the browser-cli browser extension."""
|
|
10
|
+
|
|
11
|
+
@extension_group.command("info")
|
|
12
|
+
@handle_errors
|
|
13
|
+
def extension_info():
|
|
14
|
+
"""Show extension version and advertised capabilities."""
|
|
15
|
+
info = client_from_ctx().extension.info()
|
|
16
|
+
for key in ("name", "version", "id", "platform"):
|
|
17
|
+
if key in info:
|
|
18
|
+
console.print(f"[bold]{key}:[/bold] {info[key]}")
|
|
19
|
+
caps = info.get("capabilities") or []
|
|
20
|
+
if caps:
|
|
21
|
+
console.print("[bold]capabilities:[/bold]")
|
|
22
|
+
for cap in caps:
|
|
23
|
+
console.print(f" - {cap}")
|
|
24
|
+
|
|
25
|
+
@extension_group.command("capabilities")
|
|
26
|
+
@handle_errors
|
|
27
|
+
def extension_capabilities():
|
|
28
|
+
"""Print extension feature capability strings."""
|
|
29
|
+
for cap in client_from_ctx().extension.capabilities():
|
|
30
|
+
console.print(cap)
|
|
31
|
+
|
|
32
|
+
@extension_group.command("reload")
|
|
33
|
+
@handle_errors
|
|
34
|
+
def extension_reload():
|
|
35
|
+
"""Reload the browser-cli extension service worker.
|
|
36
|
+
|
|
37
|
+
Useful after updating background.js without restarting the browser.
|
|
38
|
+
The command returns immediately; the extension restarts ~200 ms later.
|
|
39
|
+
Re-connects automatically via the keepalive alarm within ~25 seconds.
|
|
40
|
+
"""
|
|
41
|
+
client_from_ctx().extension.reload()
|
|
42
|
+
console.print("[green]Extension reloading…[/green] reconnects automatically")
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
import click
|
|
4
|
+
from browser_cli.commands import client_from_ctx, handle_errors
|
|
5
|
+
from rich.console import Console
|
|
6
|
+
from rich.table import Table
|
|
7
|
+
|
|
8
|
+
console = Console()
|
|
9
|
+
|
|
10
|
+
@click.group("extract")
|
|
11
|
+
def extract_group():
|
|
12
|
+
"""Extract content from the active tab."""
|
|
13
|
+
|
|
14
|
+
@extract_group.command("links")
|
|
15
|
+
@handle_errors
|
|
16
|
+
def extract_links():
|
|
17
|
+
"""Extract all links from the active tab."""
|
|
18
|
+
links = client_from_ctx().extract.links()
|
|
19
|
+
if not links:
|
|
20
|
+
console.print("[yellow]No links found[/yellow]")
|
|
21
|
+
return
|
|
22
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
23
|
+
table.add_column("Text", width=40)
|
|
24
|
+
table.add_column("URL")
|
|
25
|
+
for lnk in links:
|
|
26
|
+
table.add_row((lnk.get("text") or "")[:60], lnk.get("href") or "")
|
|
27
|
+
console.print(table)
|
|
28
|
+
|
|
29
|
+
@extract_group.command("images")
|
|
30
|
+
@handle_errors
|
|
31
|
+
def extract_images():
|
|
32
|
+
"""Extract all images from the active tab."""
|
|
33
|
+
images = client_from_ctx().extract.images()
|
|
34
|
+
if not images:
|
|
35
|
+
console.print("[yellow]No images found[/yellow]")
|
|
36
|
+
return
|
|
37
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
38
|
+
table.add_column("Alt", width=30)
|
|
39
|
+
table.add_column("Src")
|
|
40
|
+
for img in images:
|
|
41
|
+
table.add_row((img.get("alt") or "")[:40], img.get("src") or "")
|
|
42
|
+
console.print(table)
|
|
43
|
+
|
|
44
|
+
@extract_group.command("text")
|
|
45
|
+
@handle_errors
|
|
46
|
+
def extract_text():
|
|
47
|
+
"""Extract all visible text from the active tab."""
|
|
48
|
+
console.print(client_from_ctx().extract.text())
|
|
49
|
+
|
|
50
|
+
@extract_group.command("json")
|
|
51
|
+
@click.argument("selector")
|
|
52
|
+
@handle_errors
|
|
53
|
+
def extract_json(selector):
|
|
54
|
+
"""Parse and pretty-print JSON content inside SELECTOR."""
|
|
55
|
+
data = client_from_ctx().extract.json(selector)
|
|
56
|
+
console.print_json(json.dumps(data))
|
|
57
|
+
|
|
58
|
+
@extract_group.command("html")
|
|
59
|
+
@handle_errors
|
|
60
|
+
def extract_html():
|
|
61
|
+
"""Print the full HTML of the active tab to stdout."""
|
|
62
|
+
click.echo(client_from_ctx().extract.html())
|
|
63
|
+
|
|
64
|
+
@extract_group.command("markdown")
|
|
65
|
+
@click.option("--selector", help="Extract only the DOM subtree matching this CSS selector.")
|
|
66
|
+
@handle_errors
|
|
67
|
+
def extract_markdown(selector):
|
|
68
|
+
"""Extract the page's main content as Markdown."""
|
|
69
|
+
markdown = client_from_ctx().extract.markdown(selector)
|
|
70
|
+
click.echo(markdown or "", nl=not (markdown or "").endswith("\n"))
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import click
|
|
2
|
+
from browser_cli.commands import client_from_ctx, gentle_mode_option, handle_errors, print_counts
|
|
3
|
+
from rich.console import Console
|
|
4
|
+
from rich.table import Table
|
|
5
|
+
|
|
6
|
+
console = Console()
|
|
7
|
+
|
|
8
|
+
def _print_groups(groups, *, show_browser: bool = False) -> None:
|
|
9
|
+
if not groups:
|
|
10
|
+
console.print("[yellow]No groups found[/yellow]")
|
|
11
|
+
return
|
|
12
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
13
|
+
if show_browser:
|
|
14
|
+
table.add_column("Browser")
|
|
15
|
+
table.add_column("ID", style="dim", no_wrap=True)
|
|
16
|
+
table.add_column("Name")
|
|
17
|
+
table.add_column("Color", width=10)
|
|
18
|
+
table.add_column("Collapsed", width=10)
|
|
19
|
+
table.add_column("Tabs", width=6)
|
|
20
|
+
for g in groups:
|
|
21
|
+
row = [
|
|
22
|
+
(g.browser or "") if show_browser else None,
|
|
23
|
+
str(g.id),
|
|
24
|
+
g.title or "",
|
|
25
|
+
g.color or "",
|
|
26
|
+
"yes" if g.collapsed else "no",
|
|
27
|
+
str(g.tab_count),
|
|
28
|
+
]
|
|
29
|
+
table.add_row(*[value for value in row if value is not None])
|
|
30
|
+
console.print(table)
|
|
31
|
+
|
|
32
|
+
@click.group("groups")
|
|
33
|
+
def group_group():
|
|
34
|
+
"""Manage tab groups."""
|
|
35
|
+
|
|
36
|
+
@group_group.command("list")
|
|
37
|
+
@handle_errors
|
|
38
|
+
def group_list():
|
|
39
|
+
"""List all tab groups."""
|
|
40
|
+
groups = client_from_ctx().groups.list()
|
|
41
|
+
_print_groups(groups, show_browser=any(g.browser for g in groups))
|
|
42
|
+
|
|
43
|
+
@group_group.command("tabs")
|
|
44
|
+
@click.argument("group_id", type=int)
|
|
45
|
+
@handle_errors
|
|
46
|
+
def group_tabs(group_id):
|
|
47
|
+
"""List tabs inside a group."""
|
|
48
|
+
from browser_cli.commands.tabs import _print_tabs
|
|
49
|
+
_print_tabs(client_from_ctx().groups.tabs(group_id))
|
|
50
|
+
|
|
51
|
+
@group_group.command("count")
|
|
52
|
+
@handle_errors
|
|
53
|
+
def group_count():
|
|
54
|
+
"""Count all tab groups."""
|
|
55
|
+
print_counts(client_from_ctx().groups.count(), "group")
|
|
56
|
+
|
|
57
|
+
@group_group.command("query")
|
|
58
|
+
@click.argument("search")
|
|
59
|
+
@handle_errors
|
|
60
|
+
def group_query(search):
|
|
61
|
+
"""Search groups by name."""
|
|
62
|
+
_print_groups(client_from_ctx().groups.query(search))
|
|
63
|
+
|
|
64
|
+
@group_group.command("close")
|
|
65
|
+
@click.argument("group_id", type=int)
|
|
66
|
+
@gentle_mode_option("Throttle mode for large group operations.")
|
|
67
|
+
@handle_errors
|
|
68
|
+
def group_close(group_id, gentle_mode):
|
|
69
|
+
"""Close (ungroup and optionally close) a tab group."""
|
|
70
|
+
client_from_ctx().groups.close(group_id, gentle_mode=gentle_mode)
|
|
71
|
+
console.print(f"[green]Group {group_id} closed[/green]")
|
|
72
|
+
|
|
73
|
+
@group_group.command("create")
|
|
74
|
+
@click.argument("name")
|
|
75
|
+
@handle_errors
|
|
76
|
+
def group_create(name):
|
|
77
|
+
"""Create a new tab group with NAME."""
|
|
78
|
+
group = client_from_ctx().groups.create(name)
|
|
79
|
+
console.print(f"[green]Created group '{name}'[/green] (id: {group.id})")
|
|
80
|
+
|
|
81
|
+
@group_group.command("add-tab")
|
|
82
|
+
@click.argument("group")
|
|
83
|
+
@click.argument("url", required=False)
|
|
84
|
+
@handle_errors
|
|
85
|
+
def group_add_tab(group, url):
|
|
86
|
+
"""Open a new tab (optionally at URL) inside GROUP (name or ID)."""
|
|
87
|
+
tab_id = client_from_ctx().groups.add_tab(group, url)
|
|
88
|
+
label = url or "new tab"
|
|
89
|
+
console.print(f"[green]Opened {label}[/green] in group '{group}' (tab id: {tab_id})")
|
|
90
|
+
|
|
91
|
+
@group_group.command("move")
|
|
92
|
+
@click.argument("group")
|
|
93
|
+
@click.option("-f", "--forward", "forward", is_flag=True, help="Move group one position to the right")
|
|
94
|
+
@click.option("-b", "--backward", "backward", is_flag=True, help="Move group one position to the left")
|
|
95
|
+
@click.option("-r", "--right", "forward", is_flag=True, help="Move group one position to the right")
|
|
96
|
+
@click.option("-l", "--left", "backward", is_flag=True, help="Move group one position to the left")
|
|
97
|
+
@handle_errors
|
|
98
|
+
def group_move(group, forward, backward):
|
|
99
|
+
"""Move a tab group forward/backward or right/left (name or ID)."""
|
|
100
|
+
if not forward and not backward:
|
|
101
|
+
console.print("[red]Specify --forward/--right or --backward/--left[/red]")
|
|
102
|
+
raise SystemExit(1)
|
|
103
|
+
result = client_from_ctx().groups.move(group, forward=forward, backward=backward)
|
|
104
|
+
if isinstance(result, dict) and not result.get("moved"):
|
|
105
|
+
console.print(f"[yellow]Group '{group}' is already at the {'end' if forward else 'start'}[/yellow]")
|
|
106
|
+
else:
|
|
107
|
+
direction = "forward" if forward else "backward"
|
|
108
|
+
console.print(f"[green]Group '{group}' moved {direction}[/green]")
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
"""Native Messaging host installation command."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import json
|
|
5
|
+
import sys
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
import click
|
|
9
|
+
from rich.console import Console
|
|
10
|
+
|
|
11
|
+
from browser_cli.constants import (
|
|
12
|
+
ALLOWED_EXTENSION_IDS,
|
|
13
|
+
EXTENSION_ID,
|
|
14
|
+
NATIVE_HOST_DIRS,
|
|
15
|
+
NATIVE_HOST_NAME,
|
|
16
|
+
SUPPORTED_BROWSERS,
|
|
17
|
+
WEBSTORE_EXTENSION_ID,
|
|
18
|
+
WINDOWS_NATIVE_HOST_REGISTRY_KEYS,
|
|
19
|
+
)
|
|
20
|
+
from browser_cli.platform import install_base_dir, is_windows
|
|
21
|
+
|
|
22
|
+
console = Console()
|
|
23
|
+
|
|
24
|
+
def native_host_exe() -> Path:
|
|
25
|
+
base = install_base_dir()
|
|
26
|
+
if is_windows():
|
|
27
|
+
return base / "libexec" / "browser-cli-native-host.cmd"
|
|
28
|
+
return base / "libexec" / "browser-cli-native-host"
|
|
29
|
+
|
|
30
|
+
def write_native_host_exe(path: Path) -> None:
|
|
31
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
32
|
+
if is_windows():
|
|
33
|
+
path.write_text(
|
|
34
|
+
f'@echo off\r\n"{sys.executable}" -c "from browser_cli.native.host import main; main()" %*\r\n',
|
|
35
|
+
encoding="utf-8",
|
|
36
|
+
)
|
|
37
|
+
else:
|
|
38
|
+
path.write_text(f'#!{sys.executable}\nfrom browser_cli.native.host import main\nmain()\n')
|
|
39
|
+
path.chmod(path.stat().st_mode | 0o111)
|
|
40
|
+
|
|
41
|
+
def _windows_registry_views():
|
|
42
|
+
import winreg
|
|
43
|
+
return [0, getattr(winreg, "KEY_WOW64_32KEY", 0), getattr(winreg, "KEY_WOW64_64KEY", 0)]
|
|
44
|
+
|
|
45
|
+
def _register_windows_native_host(browser: str, manifest_path: Path) -> list[str]:
|
|
46
|
+
import winreg
|
|
47
|
+
|
|
48
|
+
installed = []
|
|
49
|
+
for key_path in WINDOWS_NATIVE_HOST_REGISTRY_KEYS[browser]:
|
|
50
|
+
full_key = f"{key_path}\\{NATIVE_HOST_NAME}"
|
|
51
|
+
for view in _windows_registry_views():
|
|
52
|
+
try:
|
|
53
|
+
access = winreg.KEY_WRITE | view
|
|
54
|
+
key = winreg.CreateKeyEx(winreg.HKEY_CURRENT_USER, full_key, 0, access)
|
|
55
|
+
with key:
|
|
56
|
+
winreg.SetValueEx(key, "", 0, winreg.REG_SZ, str(manifest_path))
|
|
57
|
+
installed.append(f"HKCU\\{full_key}")
|
|
58
|
+
except OSError as e:
|
|
59
|
+
console.print(f"[yellow]Could not write registry key {full_key}: {e}[/yellow]")
|
|
60
|
+
return installed
|
|
61
|
+
|
|
62
|
+
@click.command("install")
|
|
63
|
+
@click.argument("browser", type=click.Choice(SUPPORTED_BROWSERS), default="chrome")
|
|
64
|
+
def cmd_install(browser):
|
|
65
|
+
"""Register the native messaging host and print extension load instructions."""
|
|
66
|
+
host_exe = native_host_exe()
|
|
67
|
+
write_native_host_exe(host_exe)
|
|
68
|
+
|
|
69
|
+
ext_url = {
|
|
70
|
+
"chrome": "chrome://extensions",
|
|
71
|
+
"chromium": "chrome://extensions",
|
|
72
|
+
"brave": "brave://extensions",
|
|
73
|
+
"edge": "edge://extensions",
|
|
74
|
+
"vivaldi": "vivaldi://extensions",
|
|
75
|
+
}[browser]
|
|
76
|
+
console.print("\n[bold]Step 1:[/bold] Load the extension in your browser")
|
|
77
|
+
console.print(f" 1. Open [cyan]{ext_url}[/cyan]")
|
|
78
|
+
console.print(" 2. Enable [bold]Developer mode[/bold] (top-right toggle)")
|
|
79
|
+
console.print(f" 3. Click [bold]Load unpacked[/bold] → select: [cyan]{Path(__file__).parent.parent.parent / 'extension'}[/cyan]")
|
|
80
|
+
console.print(f" 4. Testing extension ID will be [cyan]{EXTENSION_ID}[/cyan] (fixed by built-in key)")
|
|
81
|
+
console.print(f" Chrome Web Store extension ID is [cyan]{WEBSTORE_EXTENSION_ID}[/cyan]\n")
|
|
82
|
+
|
|
83
|
+
manifest = {
|
|
84
|
+
"name": NATIVE_HOST_NAME,
|
|
85
|
+
"description": "browser-cli native messaging host",
|
|
86
|
+
"path": str(host_exe),
|
|
87
|
+
"type": "stdio",
|
|
88
|
+
"allowed_origins": [f"chrome-extension://{extension_id}/" for extension_id in ALLOWED_EXTENSION_IDS],
|
|
89
|
+
}
|
|
90
|
+
installed = _install_manifest(browser, host_exe, manifest)
|
|
91
|
+
if not installed:
|
|
92
|
+
console.print("[red]Failed to install native host manifest[/red]")
|
|
93
|
+
sys.exit(1)
|
|
94
|
+
|
|
95
|
+
for p in installed:
|
|
96
|
+
label = "Registered native host" if is_windows() else "Wrote native host manifest"
|
|
97
|
+
console.print(f"[green]✓[/green] {label}: {p}")
|
|
98
|
+
console.print(f"[green]✓[/green] Installed native host: {host_exe}")
|
|
99
|
+
console.print(f"\n[bold]Step 2:[/bold] Restart {browser.capitalize()} completely (quit app, then reopen)")
|
|
100
|
+
console.print("\n[green bold]✓ Installation complete![/green bold]")
|
|
101
|
+
console.print(" After restarting the browser, try: [cyan]browser-cli tabs list[/cyan]")
|
|
102
|
+
|
|
103
|
+
def _install_manifest(browser: str, host_exe: Path, manifest: dict) -> list:
|
|
104
|
+
if is_windows():
|
|
105
|
+
manifest_dir = host_exe.parent
|
|
106
|
+
manifest_dir.mkdir(parents=True, exist_ok=True)
|
|
107
|
+
manifest_path = manifest_dir / f"{NATIVE_HOST_NAME}.json"
|
|
108
|
+
manifest_path.write_text(json.dumps(manifest, indent=2), encoding="utf-8")
|
|
109
|
+
return _register_windows_native_host(browser, manifest_path)
|
|
110
|
+
|
|
111
|
+
platform = "darwin" if sys.platform == "darwin" else "linux"
|
|
112
|
+
installed = []
|
|
113
|
+
for directory in NATIVE_HOST_DIRS[browser][platform]:
|
|
114
|
+
try:
|
|
115
|
+
directory.mkdir(parents=True, exist_ok=True)
|
|
116
|
+
manifest_path = directory / f"{NATIVE_HOST_NAME}.json"
|
|
117
|
+
manifest_path.write_text(json.dumps(manifest, indent=2))
|
|
118
|
+
installed.append(manifest_path)
|
|
119
|
+
except Exception as e:
|
|
120
|
+
console.print(f"[yellow]Could not write to {directory}: {e}[/yellow]")
|
|
121
|
+
return installed
|