diff --git a/browser_cli/client.py b/browser_cli/client.py index 61faeda..fd62d13 100644 --- a/browser_cli/client.py +++ b/browser_cli/client.py @@ -93,29 +93,47 @@ def _remote_display_name(endpoint: str, profile_name: str, display_name: str) -> return f"{remote_name}:{display_name or profile_name}" +def remote_browser_targets(endpoint: str, token: str | None = None) -> list[BrowserTarget]: + """Return browser targets advertised by a single remote endpoint.""" + remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token) + targets: list[BrowserTarget] = [] + for item in remote_targets or []: + profile = str(item.get("profile") or "default") + display = str(item.get("displayName") or profile) + targets.append( + BrowserTarget( + profile=profile, + display_name=_remote_display_name(endpoint, profile, display), + socket_path="", + remote=endpoint, + token=token, + ) + ) + return targets + + def _remote_browser_targets() -> list[BrowserTarget]: targets: list[BrowserTarget] = [] for endpoint, cfg in _load_remotes().items(): token = str(cfg.get("token") or "") or None try: - remote_targets = send_command("browser-cli.targets", remote=endpoint, token=token) + targets.extend(remote_browser_targets(endpoint, token)) except (BrowserNotConnected, RuntimeError): continue - for item in remote_targets or []: - profile = str(item.get("profile") or "default") - display = str(item.get("displayName") or profile) - targets.append( - BrowserTarget( - profile=profile, - display_name=_remote_display_name(endpoint, profile, display), - socket_path="", - remote=endpoint, - token=token, - ) - ) return targets +def remote_target_for_alias(alias: str | None) -> BrowserTarget | None: + """Resolve a user-facing remote alias such as 'host:profile' to a target.""" + if not alias: + return None + for target in _remote_browser_targets(): + endpoint_profile = f"{target.remote}:{target.profile}" if target.remote else None + if alias in {target.display_name, endpoint_profile}: + return target + return None + + def active_browser_targets(*, include_remotes: bool = True) -> list[BrowserTarget]: targets: list[BrowserTarget] = [] if REGISTRY_PATH.exists(): @@ -164,10 +182,42 @@ def _resolve_socket(profile: str | None = None) -> str: ) +def _send_remote(endpoint: str, framed: bytes) -> bytes: + host, _, port_str = endpoint.rpartition(":") + if not host or not port_str: + raise BrowserNotConnected(f"Invalid remote endpoint '{endpoint}': expected host:port") + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: + sock.connect((host, int(port_str))) + sock.sendall(framed) + return _recv_all(sock) + + +def _auto_route_remote(endpoint: str, token: str | None) -> str | None: + targets = remote_browser_targets(endpoint, token) + if len(targets) == 1: + return targets[0].profile + if len(targets) > 1: + aliases = [target.profile for target in targets] + examples = "\n".join(f" browser-cli --remote {endpoint} --browser {a} ..." for a in aliases) + raise BrowserNotConnected( + f"Multiple remote browser instances are active: {', '.join(aliases)}\n" + f"Use --browser to select one:\n{examples}" + ) + return None + + def send_command(command: str, args: dict | None = None, profile: str | None = None, remote: str | None = None, token: str | None = None) -> Any: """Send a command to the browser and return the response data.""" + requested_profile = profile or os.environ.get("BROWSER_CLI_PROFILE") remote_endpoint = remote or os.environ.get("BROWSER_CLI_REMOTE") - resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or token_for_remote(remote_endpoint) + remote_alias_target = None + if not remote_endpoint and requested_profile: + remote_alias_target = remote_target_for_alias(requested_profile) + if remote_alias_target: + remote_endpoint = remote_alias_target.remote + requested_profile = remote_alias_target.profile + + resolved_token = token or os.environ.get("BROWSER_CLI_TOKEN") or (remote_alias_target.token if remote_alias_target else None) or token_for_remote(remote_endpoint) msg = { "id": str(uuid.uuid4()), "command": command, @@ -176,7 +226,9 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N if remote_endpoint: if resolved_token: msg["token"] = resolved_token - route_profile = profile or os.environ.get("BROWSER_CLI_PROFILE") + route_profile = requested_profile + if not route_profile and command != "browser-cli.targets": + route_profile = _auto_route_remote(remote_endpoint, resolved_token) if route_profile: msg["_route"] = route_profile payload = json.dumps(msg).encode("utf-8") @@ -184,13 +236,7 @@ def send_command(command: str, args: dict | None = None, profile: str | None = N try: if remote_endpoint: - host, _, port_str = remote_endpoint.rpartition(":") - if not host or not port_str: - raise BrowserNotConnected(f"Invalid remote endpoint '{remote_endpoint}': expected host:port") - with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as sock: - sock.connect((host, int(port_str))) - sock.sendall(framed) - response = _recv_all(sock) + response = _send_remote(remote_endpoint, framed) elif is_windows(): sock_path = _resolve_socket(profile) with PipeClient(sock_path, family="AF_PIPE") as conn: diff --git a/browser_cli/commands/groups.py b/browser_cli/commands/groups.py index 89554d3..63d118c 100644 --- a/browser_cli/commands/groups.py +++ b/browser_cli/commands/groups.py @@ -1,5 +1,5 @@ import click -from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command +from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command from rich.console import Console from rich.table import Table @@ -30,7 +30,11 @@ def _multi_browser_targets(): root = click.get_current_context().find_root() if root.obj.get("browser_explicit"): return [] - targets = active_browser_targets() + remote = root.obj.get("remote") + if remote: + targets = remote_browser_targets(remote, root.obj.get("token")) + else: + targets = active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets diff --git a/browser_cli/commands/session.py b/browser_cli/commands/session.py index 1c31453..d8da500 100644 --- a/browser_cli/commands/session.py +++ b/browser_cli/commands/session.py @@ -1,5 +1,5 @@ import click -from browser_cli.client import active_browser_targets, send_command, BrowserNotConnected +from browser_cli.client import active_browser_targets, remote_browser_targets, send_command, BrowserNotConnected from rich.console import Console console = Console() @@ -29,7 +29,11 @@ def _multi_browser_targets(): root = click.get_current_context().find_root() if root.obj.get("browser_explicit"): return [] - targets = active_browser_targets() + remote = root.obj.get("remote") + if remote: + targets = remote_browser_targets(remote, root.obj.get("token")) + else: + targets = active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets diff --git a/browser_cli/commands/tabs.py b/browser_cli/commands/tabs.py index c500e56..b1a4fac 100644 --- a/browser_cli/commands/tabs.py +++ b/browser_cli/commands/tabs.py @@ -1,6 +1,6 @@ import base64 import click -from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command +from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command from rich.console import Console from rich.table import Table @@ -31,7 +31,11 @@ def _multi_browser_targets(): root = click.get_current_context().find_root() if root.obj.get("browser_explicit"): return [] - targets = active_browser_targets() + remote = root.obj.get("remote") + if remote: + targets = remote_browser_targets(remote, root.obj.get("token")) + else: + targets = active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets diff --git a/browser_cli/commands/windows.py b/browser_cli/commands/windows.py index 300f194..41eb294 100644 --- a/browser_cli/commands/windows.py +++ b/browser_cli/commands/windows.py @@ -1,5 +1,5 @@ import click -from browser_cli.client import BrowserNotConnected, active_browser_targets, send_command +from browser_cli.client import BrowserNotConnected, active_browser_targets, remote_browser_targets, send_command from rich.console import Console from rich.table import Table @@ -30,7 +30,11 @@ def _multi_browser_targets(): root = click.get_current_context().find_root() if root.obj.get("browser_explicit"): return [] - targets = active_browser_targets() + remote = root.obj.get("remote") + if remote: + targets = remote_browser_targets(remote, root.obj.get("token")) + else: + targets = active_browser_targets() if len(targets) <= 1 and not any(target.remote for target in targets): return [] return targets diff --git a/tests/test_cli.py b/tests/test_cli.py index d3b433b..35c9ae2 100644 --- a/tests/test_cli.py +++ b/tests/test_cli.py @@ -135,7 +135,9 @@ def test_install_windows_registers_native_host(tmp_path, monkeypatch): assert any("@echo off" in text for text in wrapper_writes) def test_clients_exits_cleanly_when_registry_is_missing(): - with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")): + with patch("browser_cli.cli.REGISTRY_PATH", Path("/nonexistent/browser-cli-registry.json")), patch( + "browser_cli.cli.active_browser_targets", return_value=[] + ): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 1 @@ -153,7 +155,7 @@ def test_clients_reads_registry_with_trailing_garbage(tmp_path): with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch( "browser_cli.cli.send_command", side_effect=fake_send_command - ): + ), patch("browser_cli.cli.active_browser_targets", return_value=[]): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 0 @@ -208,7 +210,7 @@ def test_clients_shows_named_profile_and_uses_socket_uuid_for_default(tmp_path): with patch("browser_cli.cli.REGISTRY_PATH", registry_path), patch( "browser_cli.cli.send_command", side_effect=fake_send_command - ): + ), patch("browser_cli.cli.active_browser_targets", return_value=[]): result = CliRunner().invoke(main, ["clients"]) assert result.exit_code == 0 @@ -238,6 +240,25 @@ def test_tabs_list_multi_browser_shows_browser_column(): assert "work" in result.output +def test_tabs_list_with_remote_uses_only_remote_targets(): + with patch( + "browser_cli.commands.tabs.active_browser_targets", + side_effect=AssertionError("local targets should not be used for explicit remote"), + ), patch( + "browser_cli.commands.tabs.remote_browser_targets", + return_value=[BrowserTarget("work", "remote-host:work", "", remote="remote-host:8765", token="secret")], + ), patch( + "browser_cli.commands.tabs.send_command", + return_value=[{"id": 1, "windowId": 1, "active": True, "title": "Remote", "url": "https://example.com"}], + ) as send_command, patch("browser_cli.cli.save_remote_token"): + result = CliRunner().invoke(main, ["--remote", "remote-host:8765", "--token", "secret", "tabs", "list"]) + + assert result.exit_code == 0 + assert "remote-host:work" in result.output + assert "Remote" in result.output + send_command.assert_called_once_with("tabs.list", {}, profile="work", remote="remote-host:8765", token="secret") + + def test_tabs_list_with_explicit_browser_does_not_show_browser_column(): with patch( "browser_cli.commands.tabs.active_browser_targets", diff --git a/tests/test_client.py b/tests/test_client.py index ed5072c..588a525 100644 --- a/tests/test_client.py +++ b/tests/test_client.py @@ -5,10 +5,13 @@ import pytest from browser_cli.client import ( BrowserNotConnected, + BrowserTarget, _resolve_socket, active_browser_targets, display_browser_name, save_remote_token, + send_command, + remote_target_for_alias, token_for_remote, ) from browser_cli.platform import endpoint_for_alias @@ -104,6 +107,83 @@ def test_save_remote_token_persists_per_endpoint(monkeypatch, tmp_path): } +def test_send_command_auto_routes_single_remote_target(monkeypatch): + monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False) + sent = {} + + monkeypatch.setattr( + "browser_cli.client.remote_browser_targets", + lambda endpoint, token=None: [BrowserTarget("work", "host:work", "", remote=endpoint, token=token)], + ) + + def fake_send_remote(endpoint, framed): + payload_len = int.from_bytes(framed[:4], "little") + msg = json.loads(framed[4:4 + payload_len]) + sent.update(msg) + return json.dumps({"success": True, "data": "ok"}).encode("utf-8") + + monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote) + + assert send_command("tabs.list", remote="host:8765", token="secret") == "ok" + assert sent["_route"] == "work" + assert sent["token"] == "secret" + + +def test_send_command_resolves_browser_alias_to_remote_target(monkeypatch): + monkeypatch.delenv("BROWSER_CLI_REMOTE", raising=False) + monkeypatch.delenv("BROWSER_CLI_TOKEN", raising=False) + monkeypatch.setenv("BROWSER_CLI_PROFILE", "host:work") + sent = {} + + monkeypatch.setattr( + "browser_cli.client._remote_browser_targets", + lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")], + ) + + def fake_send_remote(endpoint, framed): + payload_len = int.from_bytes(framed[:4], "little") + msg = json.loads(framed[4:4 + payload_len]) + sent["endpoint"] = endpoint + sent.update(msg) + return json.dumps({"success": True, "data": []}).encode("utf-8") + + monkeypatch.setattr("browser_cli.client._send_remote", fake_send_remote) + + assert send_command("tabs.list") == [] + assert sent["endpoint"] == "host:8765" + assert sent["_route"] == "work" + assert sent["token"] == "secret" + + +def test_remote_target_for_alias_accepts_full_endpoint_profile(monkeypatch): + monkeypatch.setattr( + "browser_cli.client._remote_browser_targets", + lambda: [BrowserTarget("work", "host:work", "", remote="host:8765", token="secret")], + ) + + target = remote_target_for_alias("host:8765:work") + + assert target is not None + assert target.profile == "work" + assert target.remote == "host:8765" + + +def test_send_command_requires_browser_for_multiple_remote_targets(monkeypatch): + monkeypatch.delenv("BROWSER_CLI_PROFILE", raising=False) + monkeypatch.setattr( + "browser_cli.client.remote_browser_targets", + lambda endpoint, token=None: [ + BrowserTarget("main", "host:main", "", remote=endpoint, token=token), + BrowserTarget("furry", "host:furry", "", remote=endpoint, token=token), + ], + ) + + with pytest.raises(BrowserNotConnected, match="Multiple remote browser instances are active: main, furry"): + send_command("tabs.list", remote="host:8765", token="secret") + + def test_active_browser_targets_includes_remote_targets(monkeypatch, tmp_path): remotes_path = tmp_path / "remotes.json" endpoint = "browser-host.example:8765"