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,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