real-browser-cli 0.14.2__py3-none-any.whl

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. browser_cli/__init__.py +164 -0
  2. browser_cli/async_sdk.py +237 -0
  3. browser_cli/auth.py +263 -0
  4. browser_cli/cli.py +151 -0
  5. browser_cli/client/__init__.py +47 -0
  6. browser_cli/client/auth.py +63 -0
  7. browser_cli/client/core.py +200 -0
  8. browser_cli/client/messages.py +45 -0
  9. browser_cli/client/targets.py +95 -0
  10. browser_cli/command_security.py +119 -0
  11. browser_cli/commands/__init__.py +81 -0
  12. browser_cli/commands/auth.py +157 -0
  13. browser_cli/commands/clients.py +173 -0
  14. browser_cli/commands/completion.py +56 -0
  15. browser_cli/commands/doctor.py +90 -0
  16. browser_cli/commands/dom.py +191 -0
  17. browser_cli/commands/events.py +52 -0
  18. browser_cli/commands/extension.py +42 -0
  19. browser_cli/commands/extract.py +70 -0
  20. browser_cli/commands/groups.py +108 -0
  21. browser_cli/commands/install.py +121 -0
  22. browser_cli/commands/navigate.py +96 -0
  23. browser_cli/commands/page.py +26 -0
  24. browser_cli/commands/perf.py +47 -0
  25. browser_cli/commands/raw.py +23 -0
  26. browser_cli/commands/remote.py +68 -0
  27. browser_cli/commands/script.py +68 -0
  28. browser_cli/commands/search.py +79 -0
  29. browser_cli/commands/serve.py +117 -0
  30. browser_cli/commands/serve_http.py +115 -0
  31. browser_cli/commands/session.py +163 -0
  32. browser_cli/commands/storage.py +36 -0
  33. browser_cli/commands/tabs.py +252 -0
  34. browser_cli/commands/watch.py +60 -0
  35. browser_cli/commands/windows.py +87 -0
  36. browser_cli/commands/workspace.py +91 -0
  37. browser_cli/compat/__init__.py +4 -0
  38. browser_cli/compat/auth.py +44 -0
  39. browser_cli/compat/commands.py +43 -0
  40. browser_cli/constants.py +95 -0
  41. browser_cli/endpoints.py +55 -0
  42. browser_cli/errors.py +9 -0
  43. browser_cli/framing.py +83 -0
  44. browser_cli/local_transport.py +64 -0
  45. browser_cli/markdown/__init__.py +8 -0
  46. browser_cli/markdown/html.py +259 -0
  47. browser_cli/markdown/render.py +188 -0
  48. browser_cli/models.py +182 -0
  49. browser_cli/native/__init__.py +1 -0
  50. browser_cli/native/host.py +211 -0
  51. browser_cli/native/local_server.py +111 -0
  52. browser_cli/native/protocol.py +30 -0
  53. browser_cli/platform.py +34 -0
  54. browser_cli/registry.py +99 -0
  55. browser_cli/remote/__init__.py +1 -0
  56. browser_cli/remote/registry.py +53 -0
  57. browser_cli/remote/transport.py +230 -0
  58. browser_cli/sdk/__init__.py +48 -0
  59. browser_cli/sdk/base.py +116 -0
  60. browser_cli/sdk/browser_data.py +37 -0
  61. browser_cli/sdk/decorators.py +107 -0
  62. browser_cli/sdk/dom.py +169 -0
  63. browser_cli/sdk/extension.py +24 -0
  64. browser_cli/sdk/factories.py +103 -0
  65. browser_cli/sdk/groups.py +51 -0
  66. browser_cli/sdk/navigation.py +122 -0
  67. browser_cli/sdk/perf.py +23 -0
  68. browser_cli/sdk/routing.py +149 -0
  69. browser_cli/sdk/session.py +72 -0
  70. browser_cli/sdk/tabs.py +213 -0
  71. browser_cli/sdk/windows.py +26 -0
  72. browser_cli/sdk/workflow_decorators.py +200 -0
  73. browser_cli/serve/__init__.py +0 -0
  74. browser_cli/serve/auth.py +107 -0
  75. browser_cli/serve/control.py +59 -0
  76. browser_cli/serve/logging.py +16 -0
  77. browser_cli/serve/proxy.py +79 -0
  78. browser_cli/serve/runtime.py +196 -0
  79. browser_cli/transport.py +214 -0
  80. browser_cli/version_manager.py +17 -0
  81. real_browser_cli-0.14.2.dist-info/METADATA +87 -0
  82. real_browser_cli-0.14.2.dist-info/RECORD +85 -0
  83. real_browser_cli-0.14.2.dist-info/WHEEL +4 -0
  84. real_browser_cli-0.14.2.dist-info/entry_points.txt +2 -0
  85. real_browser_cli-0.14.2.dist-info/licenses/LICENSE +75 -0
@@ -0,0 +1,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 ""))