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
browser_cli/sdk/dom.py ADDED
@@ -0,0 +1,169 @@
1
+ """DOM, content-extraction, and page-info namespaces: ``b.dom.*``, ``b.extract.*``, ``b.page.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.sdk.base import Namespace, sdk_command
5
+
6
+ def _selector_args(self, selector):
7
+ return {"selector": selector}
8
+
9
+ def _selector_value_args(self, selector, value):
10
+ return {"selector": selector, "value": value}
11
+
12
+ def _extract_markdown(self, result, *args, **kwargs) -> str:
13
+ from browser_cli.markdown import render_markdown
14
+
15
+ return render_markdown(result)
16
+
17
+ class DomNS(Namespace):
18
+ """Query and drive page elements in the active (or specified) tab."""
19
+
20
+ @sdk_command("dom.query", _selector_args, default=[])
21
+ def query(self, selector: str) -> list[dict]:
22
+ """Return elements matching a CSS selector."""
23
+
24
+ @sdk_command("dom.click", _selector_args, return_result=False)
25
+ def click(self, selector: str) -> None:
26
+ """Click the first element matching a CSS selector."""
27
+
28
+ @sdk_command("dom.type", lambda self, selector, text: {"selector": selector, "text": text}, return_result=False)
29
+ def type(self, selector: str, text: str) -> None:
30
+ """Type text into the first matching element."""
31
+
32
+ @sdk_command("dom.attr", lambda self, selector, attr: {"selector": selector, "attr": attr}, default=[])
33
+ def attr(self, selector: str, attr: str) -> list[str]:
34
+ """Return an attribute from all matching elements."""
35
+
36
+ @sdk_command("dom.text", _selector_args, default=[])
37
+ def text(self, selector: str) -> list[str]:
38
+ """Return text from all matching elements."""
39
+
40
+ @sdk_command("dom.exists", _selector_args, default=False)
41
+ def exists(self, selector: str) -> bool:
42
+ """Return whether a selector exists."""
43
+
44
+ @sdk_command("dom.scroll", lambda self, selector=None, *, x=None, y=None: {"selector": selector, "x": x, "y": y}, return_result=False)
45
+ def scroll(self, selector: str | None = None, *, x: int | None = None, y: int | None = None) -> None:
46
+ """Scroll to a CSS selector or to pixel coordinates."""
47
+
48
+ @sdk_command("dom.select", _selector_value_args, return_result=False)
49
+ def select(self, selector: str, value: str) -> None:
50
+ """Set the value of a <select> element."""
51
+
52
+ @sdk_command("dom.eval", lambda self, code, tab_id=None: {"code": code, "tabId": tab_id})
53
+ def eval(self, code: str, tab_id: int | None = None):
54
+ """Evaluate JavaScript in the page's main world and return the result."""
55
+
56
+ @sdk_command("dom.key", lambda self, key, selector=None: {"key": key, "selector": selector}, return_result=False)
57
+ def key(self, key: str, selector: str | None = None) -> None:
58
+ """Dispatch a keyboard event. key examples: 'Enter', 'Tab', 'Escape', 'ArrowDown'."""
59
+
60
+ @sdk_command("dom.hover", _selector_args, return_result=False)
61
+ def hover(self, selector: str) -> None:
62
+ """Dispatch mouseover/mouseenter on an element."""
63
+
64
+ @sdk_command("dom.check", _selector_args, return_result=False)
65
+ def check(self, selector: str) -> None:
66
+ """Check a checkbox."""
67
+
68
+ @sdk_command("dom.uncheck", _selector_args, return_result=False)
69
+ def uncheck(self, selector: str) -> None:
70
+ """Uncheck a checkbox."""
71
+
72
+ @sdk_command("dom.clear", _selector_args, return_result=False)
73
+ def clear(self, selector: str) -> None:
74
+ """Clear the value of an input element."""
75
+
76
+ @sdk_command("dom.focus", _selector_args, return_result=False)
77
+ def focus(self, selector: str) -> None:
78
+ """Focus an element."""
79
+
80
+ @sdk_command("dom.submit", _selector_args, return_result=False)
81
+ def submit(self, selector: str) -> None:
82
+ """Submit the form containing the matched element."""
83
+
84
+ def poll(
85
+ self,
86
+ selector: str,
87
+ pattern: str,
88
+ *,
89
+ attr: str | None = None,
90
+ timeout: float = 30.0,
91
+ interval: float = 0.5,
92
+ tab_id: int | None = None,
93
+ ) -> dict:
94
+ """Poll selector's text/value until it matches regex pattern.
95
+
96
+ Returns ``{"selector": ..., "value": ..., "pattern": ...}`` when matched.
97
+ """
98
+ return self.command("dom.poll", {
99
+ "selector": selector,
100
+ "pattern": pattern,
101
+ "attr": attr,
102
+ "timeout": int(timeout * 1000),
103
+ "interval": int(interval * 1000),
104
+ "tabId": tab_id,
105
+ })
106
+
107
+ def wait_for(
108
+ self,
109
+ selector: str,
110
+ *,
111
+ timeout: float = 10.0,
112
+ visible: bool = False,
113
+ hidden: bool = False,
114
+ tab_id: int | None = None,
115
+ ) -> dict:
116
+ """Wait until a CSS selector appears (or disappears) in the DOM.
117
+
118
+ Args:
119
+ selector: CSS selector to watch.
120
+ timeout: Max seconds to wait before raising ``RuntimeError``.
121
+ visible: Wait until the element has non-zero dimensions.
122
+ hidden: Wait until the element is absent or has ``offsetParent == null``.
123
+ tab_id: Tab to watch. Defaults to the active tab.
124
+ """
125
+ return self.command("dom.wait_for", {
126
+ "selector": selector,
127
+ "timeout": int(timeout * 1000),
128
+ "visible": visible,
129
+ "hidden": hidden,
130
+ "tabId": tab_id,
131
+ })
132
+
133
+ class ExtractNS(Namespace):
134
+ """Extract structured content from the active tab."""
135
+
136
+ @sdk_command("extract.links", default=[])
137
+ def links(self) -> list[dict]:
138
+ """Return links from the active tab."""
139
+
140
+ @sdk_command("extract.images", default=[])
141
+ def images(self) -> list[dict]:
142
+ """Return images from the active tab."""
143
+
144
+ @sdk_command("extract.text", default="")
145
+ def text(self) -> str:
146
+ """Return plain text from the active tab."""
147
+
148
+ @sdk_command("extract.json", lambda self, selector: {"selector": selector})
149
+ def json(self, selector: str):
150
+ """Extract JSON-like structured data from a selector."""
151
+
152
+ @sdk_command("extract.html", default="")
153
+ def html(self) -> str:
154
+ """Return the full HTML source of the active tab."""
155
+
156
+ @sdk_command("extract.markdown", lambda self, selector=None: {"selector": selector}, mapper=_extract_markdown)
157
+ def markdown(self, selector: str | None = None) -> str:
158
+ """Extract the page's main content as clean Markdown.
159
+
160
+ The extractor may return either Markdown or raw HTML; both are
161
+ normalized to Markdown here so SDK and CLI callers get identical output.
162
+ """
163
+
164
+ class PageNS(Namespace):
165
+ """Inspect the active page."""
166
+
167
+ @sdk_command("page.info", default={})
168
+ def info(self) -> dict:
169
+ """Return title, URL, readyState, lang, and meta tags of the active tab."""
@@ -0,0 +1,24 @@
1
+ """Extension-control namespace: ``b.extension.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.sdk.base import Namespace, sdk_command
5
+
6
+ class ExtensionNS(Namespace):
7
+ """Control the browser-cli extension itself."""
8
+
9
+ @sdk_command("extension.info", default={})
10
+ def info(self) -> dict:
11
+ """Return extension version, runtime metadata, and capabilities."""
12
+
13
+ @sdk_command("extension.capabilities", default=[])
14
+ def capabilities(self) -> list[str]:
15
+ """Return feature capability strings advertised by the extension."""
16
+
17
+ @sdk_command("extension.reload")
18
+ def reload(self) -> None:
19
+ """Reload the browser-cli extension service worker.
20
+
21
+ Schedules a ``chrome.runtime.reload()`` inside the extension and returns
22
+ immediately. The extension restarts ~200 ms later and reconnects via the
23
+ keepalive alarm within ~25 seconds.
24
+ """
@@ -0,0 +1,103 @@
1
+ """Object-factory mixin for :class:`~browser_cli.BrowserCLI`.
2
+
3
+ Builds the typed :class:`~browser_cli.models.Tab` / :class:`~browser_cli.models.Group`
4
+ dataclasses from raw command responses and binds each one to the client that
5
+ should run its actions. In multi-browser mode an object is bound to a sibling
6
+ client targeting the browser it came from, so ``tab.close()`` routes correctly.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Protocol, cast
11
+
12
+ from browser_cli.models import Group, Tab
13
+
14
+ class _FactoryClient(Protocol):
15
+ _key: str | None
16
+
17
+ class FactoryMixin:
18
+ """Turn raw response dicts into bound ``Tab``/``Group`` objects.
19
+
20
+ Mixed into :class:`~browser_cli.BrowserCLI`; relies on the client providing
21
+ ``_browser``/``_remote``/``_key`` and being constructible via ``type(self)``.
22
+ """
23
+
24
+ def tab_from(
25
+ self,
26
+ data: dict,
27
+ *,
28
+ browser_profile: str | None = None,
29
+ browser_name: str | None = None,
30
+ browser_remote: str | None = None,
31
+ ) -> Tab:
32
+ tab = Tab(
33
+ id=data["id"],
34
+ window_id=data.get("windowId", 0),
35
+ active=data.get("active", False),
36
+ muted=data.get("muted", False),
37
+ title=data.get("title") or "",
38
+ url=data.get("url") or "",
39
+ group_id=data.get("groupId") or None,
40
+ browser=browser_name,
41
+ )
42
+ client = cast(_FactoryClient, self)
43
+ tab._browser = self if browser_profile is None else cast(Any, type(self))(
44
+ browser=browser_profile,
45
+ remote=browser_remote,
46
+ key=client._key,
47
+ _command_sender=getattr(self, "_command_sender", None),
48
+ )
49
+ return tab
50
+
51
+ def require_tab_response(self, data, error: str) -> Tab:
52
+ """Build a bound Tab from a tab-shaped response, or raise ``RuntimeError(error)``."""
53
+ if not isinstance(data, dict) or "id" not in data:
54
+ raise RuntimeError(error)
55
+ return self.tab_from(data)
56
+
57
+ def group_from(
58
+ self,
59
+ data: dict,
60
+ *,
61
+ browser_profile: str | None = None,
62
+ browser_name: str | None = None,
63
+ browser_remote: str | None = None,
64
+ ) -> Group:
65
+ group = Group(
66
+ id=data["id"],
67
+ title=data.get("title") or "",
68
+ color=data.get("color") or "",
69
+ collapsed=data.get("collapsed", False),
70
+ tab_count=data.get("tabCount", 0),
71
+ browser=browser_name,
72
+ )
73
+ client = cast(_FactoryClient, self)
74
+ group._browser = self if browser_profile is None else cast(Any, type(self))(
75
+ browser=browser_profile,
76
+ remote=browser_remote,
77
+ key=client._key,
78
+ _command_sender=getattr(self, "_command_sender", None),
79
+ )
80
+ return group
81
+
82
+ def tab_from_target(self, data: dict, target) -> Tab:
83
+ """Build a Tab, tagging it with *target* in multi-browser mode (``None`` = local)."""
84
+ return self.tab_from(
85
+ data,
86
+ browser_profile=target.profile if target else None,
87
+ browser_name=target.display_name if target else None,
88
+ browser_remote=target.remote if target else None,
89
+ )
90
+
91
+ def group_from_target(self, data: dict, target) -> Group:
92
+ """Build a Group, tagging it with *target* in multi-browser mode (``None`` = local)."""
93
+ return self.group_from(
94
+ data,
95
+ browser_profile=target.profile if target else None,
96
+ browser_name=target.display_name if target else None,
97
+ browser_remote=target.remote if target else None,
98
+ )
99
+
100
+ @staticmethod
101
+ def tag_browser(item: dict, target) -> dict:
102
+ """Return *item* as-is locally, or with a ``browser`` key in multi-browser mode."""
103
+ return item if target is None else {**item, "browser": target.display_name}
@@ -0,0 +1,51 @@
1
+ """Tab groups namespace: ``b.groups.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.models import BrowserCounts, Group, Tab
5
+ from browser_cli.sdk.base import Namespace
6
+
7
+ class GroupsNS(Namespace):
8
+ """List, create, query, and modify tab groups."""
9
+
10
+ def list(self) -> list[Group]:
11
+ """Return all tab groups.
12
+
13
+ When multiple browsers are active and no browser was specified, each Group
14
+ includes ``group.browser`` naming its source browser.
15
+ """
16
+ return self.multi_list("group.list", {}, self.group_from_target)
17
+
18
+ def count(self) -> "int | BrowserCounts":
19
+ """Return the number of tab groups.
20
+
21
+ Returns ``BrowserCounts`` in implicit multi-browser mode.
22
+ """
23
+ return self.multi_count("group.count", {})
24
+
25
+ def query(self, search: str) -> list[Group]:
26
+ """Search groups by name."""
27
+ return [self.group_from(g) for g in (self.command("group.query", {"search": search}) or [])]
28
+
29
+ def create(self, name: str) -> Group:
30
+ """Create a new tab group with *name*. Returns the created Group."""
31
+ data = self.command("group.open", {"name": name})
32
+ if isinstance(data, dict):
33
+ return self.group_from(data)
34
+ return Group(id=data, title=name, color="", collapsed=False, tab_count=0)
35
+
36
+ def tabs(self, group_id: int) -> list[Tab]:
37
+ """Return all tabs inside a group."""
38
+ return [self.tab_from(t) for t in (self.command("group.tabs", {"groupId": group_id}) or [])]
39
+
40
+ def add_tab(self, group: str | int, url: str | None = None) -> int | None:
41
+ """Open a new tab (optionally at URL) inside a group. Returns the new tab ID."""
42
+ result = self.command("group.add_tab", {"group": str(group), "url": url})
43
+ return self.field(result, "tabId", fallback=result)
44
+
45
+ def move(self, group: str | int, *, forward: bool = False, backward: bool = False) -> dict | None:
46
+ """Move a tab group forward or backward. Returns the raw move result."""
47
+ return self.command("group.move", {"group": str(group), "forward": forward, "backward": backward})
48
+
49
+ def close(self, group_id: int, *, gentle_mode: str = "auto") -> None:
50
+ """Ungroup (and close) a tab group by ID."""
51
+ self.command("group.close", {"groupId": group_id, "gentleMode": gentle_mode})
@@ -0,0 +1,122 @@
1
+ """Navigation namespace: ``b.nav.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.models import Tab
5
+ from browser_cli.sdk.base import Namespace, sdk_command
6
+
7
+ def _open_args(self, url, *, background=False, focus=False, window=None, group=None, **_ignored):
8
+ return {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group}
9
+
10
+ def _tab_args(self, tab_id=None):
11
+ return {"tabId": tab_id}
12
+
13
+ class NavigationNS(Namespace):
14
+ """Open URLs, navigate history, and focus tabs."""
15
+
16
+ def open(
17
+ self,
18
+ url: str,
19
+ *,
20
+ background: bool = False,
21
+ focus: bool = False,
22
+ window: str | None = None,
23
+ group: str | None = None,
24
+ reuse: bool = False,
25
+ reuse_domain: bool = False,
26
+ reuse_title: str | None = None,
27
+ ) -> None:
28
+ """Open *url* in a new tab without stealing OS focus by default.
29
+
30
+ ``reuse``/``reuse_domain``/``reuse_title`` navigate an existing matching tab
31
+ instead of creating a new one.
32
+ """
33
+ tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
34
+ if tab is not None:
35
+ self.to(tab.id, url)
36
+ if focus:
37
+ self._c.tabs.activate(tab.id)
38
+ return None
39
+ self.command("navigate.open", _open_args(self, url, background=background, focus=focus, window=window, group=group))
40
+ return None
41
+
42
+ def open_wait(
43
+ self,
44
+ url: str,
45
+ *,
46
+ timeout: float = 30.0,
47
+ background: bool = False,
48
+ focus: bool = False,
49
+ window: str | None = None,
50
+ group: str | None = None,
51
+ reuse: bool = False,
52
+ reuse_domain: bool = False,
53
+ reuse_title: str | None = None,
54
+ ) -> Tab:
55
+ """Open *url* in a new tab and block until fully loaded. Returns the Tab."""
56
+ tab = self._reuse_target(url, reuse=reuse, reuse_domain=reuse_domain, reuse_title=reuse_title)
57
+ if tab is not None:
58
+ self.to(tab.id, url)
59
+ if focus:
60
+ self._c.tabs.activate(tab.id)
61
+ return self._c.tabs.wait_for_load(tab.id, timeout=timeout)
62
+ return self.require_tab(
63
+ self.command("navigate.open_wait", {
64
+ "url": url, "timeout": int(timeout * 1000),
65
+ "background": background or not focus, "focus": focus, "window": window, "group": group,
66
+ }),
67
+ "navigate.open_wait returned unexpected data",
68
+ )
69
+
70
+ @sdk_command("navigate.reload", _tab_args)
71
+ def reload(self, tab_id: int | None = None) -> None:
72
+ """Reload the active tab or a specific tab."""
73
+
74
+ @sdk_command("navigate.hard_reload", _tab_args)
75
+ def hard_reload(self, tab_id: int | None = None) -> None:
76
+ """Hard-reload the active tab or a specific tab."""
77
+
78
+ @sdk_command("navigate.back", _tab_args)
79
+ def back(self, tab_id: int | None = None) -> None:
80
+ """Navigate back in the active tab or a specific tab."""
81
+
82
+ @sdk_command("navigate.forward", _tab_args)
83
+ def forward(self, tab_id: int | None = None) -> None:
84
+ """Navigate forward in the active tab or a specific tab."""
85
+
86
+ @sdk_command("navigate.focus", lambda self, pattern: {"pattern": pattern})
87
+ def focus(self, pattern: str) -> dict | None:
88
+ """Focus the first tab whose URL matches *pattern*. Returns the matched tab info, if any."""
89
+
90
+ @sdk_command("navigate.to", lambda self, tab_id, url: {"tabId": tab_id, "url": url})
91
+ def to(self, tab_id: int, url: str) -> None:
92
+ """Navigate a specific tab to *url* in place."""
93
+
94
+ def _reuse_target(self, url: str, *, reuse: bool, reuse_domain: bool, reuse_title: str | None):
95
+ if not (reuse or reuse_domain or reuse_title):
96
+ return None
97
+ from urllib.parse import urlparse
98
+ wanted = urlparse(url)
99
+ wanted_host = wanted.netloc.lower()
100
+ for tab in self._c.tabs.list():
101
+ tab_url = tab.url or ""
102
+ parsed = urlparse(tab_url)
103
+ if reuse and tab_url == url:
104
+ return tab
105
+ if reuse_domain and wanted_host and parsed.netloc.lower() == wanted_host:
106
+ return tab
107
+ if reuse_title and reuse_title.lower() in (tab.title or "").lower():
108
+ return tab
109
+ return None
110
+
111
+ def search(
112
+ self, engine: str, query: str, *,
113
+ background: bool = False, focus: bool = False, window: str | None = None, group: str | None = None,
114
+ ) -> None:
115
+ """Open a search query in the given engine (e.g. 'google', 'youtube', 'ddg')."""
116
+ from urllib.parse import quote_plus
117
+ from browser_cli.commands.search import ENGINES
118
+ template = ENGINES.get(engine)
119
+ if template is None:
120
+ raise ValueError(f"Unknown search engine '{engine}'. Available: {', '.join(ENGINES)}")
121
+ url = template.format(query=quote_plus(query))
122
+ self.command("navigate.open", {"url": url, "background": background or not focus, "focus": focus, "window": window, "group": group})
@@ -0,0 +1,23 @@
1
+ """Performance and background-jobs namespace: ``b.perf.*``."""
2
+ from __future__ import annotations
3
+
4
+ from browser_cli.sdk.base import Namespace, sdk_command
5
+
6
+ class PerfNS(Namespace):
7
+ """Inspect the performance profile and manage background jobs."""
8
+
9
+ @sdk_command("perf.status", default={})
10
+ def status(self) -> dict:
11
+ """Return current performance profile, throttle info, and running jobs."""
12
+
13
+ @sdk_command("perf.set_profile", lambda self, profile: {"profile": profile}, default={})
14
+ def set_profile(self, profile: str) -> dict:
15
+ """Set the global extension performance profile."""
16
+
17
+ @sdk_command("jobs.status", lambda self, job_id: {"jobId": job_id}, default={})
18
+ def job_status(self, job_id: str) -> dict:
19
+ """Return status/progress for a background job."""
20
+
21
+ @sdk_command("jobs.cancel", lambda self, job_id: {"jobId": job_id}, default={})
22
+ def job_cancel(self, job_id: str) -> dict:
23
+ """Request cancellation for a background job."""
@@ -0,0 +1,149 @@
1
+ """Multi-browser routing mixin for :class:`~browser_cli.BrowserCLI`.
2
+
3
+ When no specific browser is selected and more than one browser (local or
4
+ remote) is active, list/count commands fan out to every target and aggregate
5
+ the results. This mixin holds that fan-out machinery plus a few small response
6
+ helpers; single-browser mode falls straight through to ``_cmd``.
7
+ """
8
+ from __future__ import annotations
9
+
10
+ import importlib
11
+ import sys
12
+ from collections.abc import Callable, Iterable
13
+ from typing import TYPE_CHECKING, Protocol, cast
14
+
15
+ from browser_cli.client import BrowserTarget
16
+ from browser_cli.errors import BrowserNotConnected
17
+ from browser_cli.models import BrowserCounts, Tab
18
+
19
+ if TYPE_CHECKING:
20
+ from browser_cli.sdk.tabs import TabsNS
21
+
22
+ class _RoutingClient(Protocol):
23
+ _browser: str | None
24
+ _remote: str | None
25
+ _key: str | None
26
+ tabs: "TabsNS"
27
+
28
+ def dispatch(self, command: str, args: dict | None = None): ...
29
+
30
+ # send_command / active_browser_targets / remote_browser_targets are resolved
31
+ # through the ``browser_cli`` package namespace at call time, not bound here at
32
+ # import, so tests patching ``browser_cli.send_command`` still take effect —
33
+ # without a module-level import cycle back into browser_cli.__init__.
34
+
35
+ _UNSET = object()
36
+
37
+ def _browser_cli_package():
38
+ return sys.modules.get("browser_cli") or importlib.import_module("browser_cli")
39
+
40
+ class RoutingMixin:
41
+ """Fan-out + aggregation across active browsers, mixed into ``BrowserCLI``.
42
+
43
+ Relies on the client exposing ``_browser``/``_remote``/``_key``, ``_cmd``,
44
+ and a ``tabs`` namespace.
45
+ """
46
+
47
+ @property
48
+ def _client(self) -> _RoutingClient:
49
+ return cast(_RoutingClient, cast(object, self))
50
+
51
+ def _multi_browser_targets(self) -> list[BrowserTarget]:
52
+ client = self._client
53
+ package = _browser_cli_package()
54
+ if client._browser is not None:
55
+ return []
56
+ if client._remote:
57
+ targets = package.remote_browser_targets(client._remote, key=client._key)
58
+ else:
59
+ targets = package.active_browser_targets()
60
+ if len(targets) <= 1 and not any(target.remote for target in targets):
61
+ return []
62
+ return targets
63
+
64
+ def _collect_multi_browser(self, command: str, args: dict | None = None):
65
+ results = []
66
+ targets = self._multi_browser_targets()
67
+ for target in targets:
68
+ try:
69
+ if target.remote:
70
+ data = _browser_cli_package().send_command(
71
+ command, args, profile=target.profile, remote=target.remote, key=self._client._key
72
+ )
73
+ else:
74
+ data = _browser_cli_package().send_command(command, args, profile=target.profile)
75
+ except (BrowserNotConnected, RuntimeError):
76
+ continue
77
+ results.append((target, data))
78
+ if results:
79
+ return results
80
+ if targets:
81
+ raise BrowserNotConnected(
82
+ "Cannot resolve a browser socket automatically.\n"
83
+ "Make sure the browser is running with the browser-cli extension enabled,\n"
84
+ "or pass --browser <alias> / set BROWSER_CLI_PROFILE to a known alias."
85
+ )
86
+ return []
87
+
88
+ @staticmethod
89
+ def _field(result, key, default=None, *, fallback=_UNSET):
90
+ """Pull *key* out of a dict response, with a non-dict fallback.
91
+
92
+ Returns ``result[key]`` (or *default*) when *result* is a dict. When it
93
+ is not a dict, returns *fallback* if given, else *default*.
94
+ """
95
+ if isinstance(result, dict):
96
+ return result.get(key, default)
97
+ return default if fallback is _UNSET else fallback
98
+
99
+ def toggle_tab(self, command: str, tab_id: int | None) -> int:
100
+ """Run a tab toggle command (mute/pin/...) and return the target tab ID."""
101
+ result = self._client.dispatch(command, {"tabId": tab_id})
102
+ return self._field(result, "tabId", tab_id, fallback=int(tab_id or 0))
103
+
104
+ def multi_count(self, command: str, args: dict | None = None) -> "int | BrowserCounts":
105
+ """Count command that aggregates into :class:`BrowserCounts` in multi-browser mode."""
106
+ multi_results = self._collect_multi_browser(command, args or {})
107
+ if not multi_results:
108
+ return self._client.dispatch(command, args or {})
109
+ by_browser = {target.display_name: int(count or 0) for target, count in multi_results}
110
+ return BrowserCounts(total=sum(by_browser.values()), by_browser=by_browser)
111
+
112
+ def multi_list(self, command: str, args: dict | None, mapper):
113
+ """List command, flattening per-browser results in multi-browser mode.
114
+
115
+ *mapper* is ``(item, target) -> mapped`` where ``target`` is the source
116
+ :class:`BrowserTarget` in multi mode, or ``None`` in single-browser mode.
117
+ """
118
+ multi_results = self._collect_multi_browser(command, args or {})
119
+ if multi_results:
120
+ return [
121
+ mapper(item, target)
122
+ for target, items in multi_results
123
+ for item in (items or [])
124
+ ]
125
+ return [mapper(item, None) for item in (self._client.dispatch(command, args or {}) or [])]
126
+
127
+ def apply_tab_filter(self, filter_fn: Callable[[Tab], bool] | Callable[[list[Tab]], Iterable[Tab]]) -> list[Tab]:
128
+ tabs = self._client.tabs.list()
129
+
130
+ try:
131
+ transformed = filter_fn(tabs)
132
+ except (AttributeError, TypeError):
133
+ return [tab for tab in tabs if filter_fn(tab)]
134
+
135
+ if isinstance(transformed, list):
136
+ return transformed
137
+ if isinstance(transformed, tuple):
138
+ return list(transformed)
139
+ if isinstance(transformed, set):
140
+ return list(transformed)
141
+ if transformed is tabs:
142
+ return tabs
143
+ if isinstance(transformed, bool):
144
+ return [tab for tab in tabs if filter_fn(tab)]
145
+
146
+ try:
147
+ return list(transformed)
148
+ except TypeError:
149
+ return [tab for tab in tabs if filter_fn(tab)]