from __future__ import annotations import argparse import curses from dataclasses import dataclass, field import json import os from pathlib import Path import re import shlex import shutil import subprocess import sys from typing import Callable, Iterable, Sequence from urllib.parse import quote from .remotes import ( RegistryError, RemoteEntry, RemoteRegistry, default_registry_path, validate_target, ) from .selector import CatalogWindow, SelectorError, SelectorResult, SessionSelector DEFAULT_SOCKET_NAME = "herdr-hub" DEFAULT_TMUX_SESSION = "hub" DEFAULT_SESSION_NAME = "default" BOOTSTRAP_WINDOW_NAME = "__herdr_hub_bootstrap__" WINDOW_KEY_OPTION = "@herdr_hub_key" WINDOW_COMMAND_OPTION = "@herdr_hub_command" WINDOW_LABEL_OPTION = "@herdr_hub_label" WINDOW_ENDPOINT_OPTION = "@herdr_hub_endpoint" WINDOW_SHORT_LABEL_OPTION = "@herdr_hub_short_label" SESSION_DISMISSED_OPTION = "@herdr_hub_dismissed" SESSION_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") class HubError(RuntimeError): """A user-facing failure.""" @dataclass(frozen=True) class Endpoint: kind: str session: str = DEFAULT_SESSION_NAME target: str | None = None remote_keybindings: str = "local" def __post_init__(self) -> None: if self.kind not in {"local", "remote"}: raise ValueError(f"unsupported endpoint kind: {self.kind}") validate_session_name(self.session) if self.kind == "local": if self.target is not None: raise ValueError("a local endpoint cannot have a remote target") return validate_remote_target(self.target) if self.remote_keybindings not in {"local", "server"}: raise ValueError("remote keybindings must be 'local' or 'server'") @classmethod def local(cls, session: str = DEFAULT_SESSION_NAME) -> Endpoint: return cls(kind="local", session=session) @classmethod def remote( cls, target: str, session: str = DEFAULT_SESSION_NAME, remote_keybindings: str = "local", ) -> Endpoint: return cls( kind="remote", target=target, session=session, remote_keybindings=remote_keybindings, ) @property def key(self) -> str: if self.kind == "local": return f"local:{quote(self.session, safe='')}" assert self.target is not None return f"remote:{quote(self.target, safe='')}:{quote(self.session, safe='')}" @property def label(self) -> str: if self.kind == "local": return truncate_label(f"L {self.session}") assert self.target is not None return truncate_label(f"R {display_target(self.target)}/{self.session}") @property def short_label(self) -> str: if self.kind == "local": label = "local" if self.session != DEFAULT_SESSION_NAME: label += f"/{self.session}" return truncate_label(label, 32) assert self.target is not None label = display_target(self.target) if self.session != DEFAULT_SESSION_NAME: label += f"/{self.session}" return truncate_label(label, 32) def command(self, herdr_bin: str) -> list[str]: command = [herdr_bin] if self.kind == "remote": assert self.target is not None command.extend(["--remote", self.target]) if self.remote_keybindings != "local": command.extend(["--remote-keybindings", self.remote_keybindings]) if self.session != DEFAULT_SESSION_NAME: command.extend(["--session", self.session]) return command @dataclass class DiscoveryReport: endpoints: list[Endpoint] = field(default_factory=list) warnings: list[str] = field(default_factory=list) def extend(self, other: DiscoveryReport) -> None: self.endpoints.extend(other.endpoints) self.warnings.extend(other.warnings) @dataclass(frozen=True) class ManagedWindow: window_id: str key: str name: str dead: bool active: bool endpoint: Endpoint | None = None RunFunction = Callable[..., subprocess.CompletedProcess[str]] def validate_session_name(name: str) -> None: if not SESSION_NAME_RE.fullmatch(name): raise ValueError( "session name must be 1-64 ASCII letters, numbers, '.', '_' or '-'" ) def validate_remote_target(target: str | None) -> None: validate_target(target or "") def truncate_label(label: str, limit: int = 48) -> str: clean = "".join( character if ord(character) >= 32 and ord(character) != 127 else "?" for character in label ) if len(clean) <= limit: return clean return clean[: limit - 1] + "…" def display_target(target: str) -> str: if target.startswith("ssh://"): return target.removeprefix("ssh://") return target def endpoint_sort_key(endpoint: Endpoint) -> tuple[int, str, str]: if endpoint.kind == "local": session_rank = "" if endpoint.session == DEFAULT_SESSION_NAME else endpoint.session return (0, "", session_rank) return (1, endpoint.target or "", endpoint.session) def dedupe_endpoints(endpoints: Iterable[Endpoint]) -> list[Endpoint]: by_key: dict[str, Endpoint] = {} for endpoint in endpoints: by_key.setdefault(endpoint.key, endpoint) return sorted(by_key.values(), key=endpoint_sort_key) def discover_local_sessions( herdr_bin: str, *, run: RunFunction = subprocess.run, ) -> DiscoveryReport: try: result = run( [herdr_bin, "session", "list", "--json"], capture_output=True, text=True, timeout=5, check=False, ) except (OSError, subprocess.SubprocessError) as error: return DiscoveryReport(warnings=[f"local Herdr session discovery failed: {error}"]) if result.returncode != 0: detail = (result.stderr or result.stdout or "unknown error").strip() return DiscoveryReport( warnings=[f"local Herdr session discovery failed: {detail}"] ) try: payload = json.loads(result.stdout) sessions = payload["sessions"] except (json.JSONDecodeError, KeyError, TypeError) as error: return DiscoveryReport( warnings=[f"local Herdr returned invalid session JSON: {error}"] ) endpoints: list[Endpoint] = [] for item in sessions: if not isinstance(item, dict) or item.get("running") is not True: continue name = item.get("name") if not isinstance(name, str): continue try: endpoints.append(Endpoint.local(name)) except ValueError: continue return DiscoveryReport(endpoints=dedupe_endpoints(endpoints)) def parse_endpoint_argv(argv: Sequence[str]) -> Endpoint | None: if not argv or Path(argv[0]).name != "herdr": return None target: str | None = None session = DEFAULT_SESSION_NAME keybindings = "local" index = 1 while index < len(argv): argument = argv[index] if argument == "--": break if argument == "--remote": if index + 1 >= len(argv): return None target = argv[index + 1] index += 2 continue if argument.startswith("--remote="): target = argument.split("=", 1)[1] index += 1 continue if argument == "--session": if index + 1 >= len(argv): return None session = argv[index + 1] index += 2 continue if argument.startswith("--session="): session = argument.split("=", 1)[1] index += 1 continue if argument == "--remote-keybindings": if index + 1 >= len(argv): return None keybindings = argv[index + 1] index += 2 continue if argument.startswith("--remote-keybindings="): keybindings = argument.split("=", 1)[1] index += 1 continue index += 1 try: return ( Endpoint.remote(target, session, keybindings) if target is not None else Endpoint.local(session) ) except ValueError: return None def parse_remote_argv(argv: Sequence[str]) -> Endpoint | None: endpoint = parse_endpoint_argv(argv) return endpoint if endpoint is not None and endpoint.kind == "remote" else None def serialize_endpoint(endpoint: Endpoint) -> str: return json.dumps( { "version": 1, "kind": endpoint.kind, "session": endpoint.session, "target": endpoint.target, "remote_keybindings": endpoint.remote_keybindings, }, ensure_ascii=False, separators=(",", ":"), ) def deserialize_endpoint(metadata: str, command: str = "") -> Endpoint | None: if metadata: try: payload = json.loads(metadata) if not isinstance(payload, dict) or payload.get("version") != 1: raise ValueError kind = payload.get("kind") session = payload.get("session") target = payload.get("target") keybindings = payload.get("remote_keybindings", "local") if not isinstance(kind, str) or not isinstance(session, str): raise ValueError if not isinstance(keybindings, str): raise ValueError if kind == "local": return Endpoint.local(session) if kind == "remote" and isinstance(target, str): return Endpoint.remote(target, session, keybindings) raise ValueError except (json.JSONDecodeError, TypeError, ValueError): pass if not command: return None try: arguments = shlex.split(command) except ValueError: return None if arguments and arguments[0] == "exec": arguments = arguments[1:] return parse_endpoint_argv(arguments) def decode_cmdline(raw: bytes) -> list[str]: return [ os.fsdecode(argument) for argument in raw.rstrip(b"\0").split(b"\0") if argument ] def discover_remote_processes( proc_root: Path = Path("/proc"), *, user_id: int | None = None, ) -> DiscoveryReport: if user_id is None: user_id = os.getuid() if not proc_root.is_dir(): return DiscoveryReport( warnings=[f"remote discovery unavailable: {proc_root} is not mounted"] ) endpoints: list[Endpoint] = [] unreadable = 0 try: entries = sorted( (entry for entry in proc_root.iterdir() if entry.name.isdigit()), key=lambda entry: int(entry.name), ) except OSError as error: return DiscoveryReport(warnings=[f"remote process discovery failed: {error}"]) for entry in entries: cmdline_path = entry / "cmdline" try: if cmdline_path.stat().st_uid != user_id: continue raw = cmdline_path.read_bytes() except (FileNotFoundError, ProcessLookupError): continue except (PermissionError, OSError): unreadable += 1 continue endpoint = parse_remote_argv(decode_cmdline(raw)) if endpoint is not None: endpoints.append(endpoint) warnings: list[str] = [] if unreadable: warnings.append(f"could not inspect {unreadable} same-user process command line(s)") return DiscoveryReport(endpoints=dedupe_endpoints(endpoints), warnings=warnings) def discover_endpoints(herdr_bin: str, proc_root: Path = Path("/proc")) -> DiscoveryReport: report = discover_local_sessions(herdr_bin) report.extend(discover_remote_processes(proc_root)) report.endpoints = dedupe_endpoints(report.endpoints) return report class TmuxHub: def __init__( self, *, tmux_bin: str, socket_name: str = DEFAULT_SOCKET_NAME, session_name: str = DEFAULT_TMUX_SESSION, config_file: str | None = None, script_path: str | None = None, python_bin: str | None = None, run: RunFunction = subprocess.run, ) -> None: self.tmux_bin = tmux_bin self.socket_name = socket_name self.session_name = session_name self.config_file = config_file self.script_path = script_path or str(Path(sys.argv[0]).absolute()) self.python_bin = python_bin or sys.executable self._run = run def base_command(self) -> list[str]: command = [self.tmux_bin, "-L", self.socket_name] if self.config_file is not None: command.extend(["-f", self.config_file]) return command def run( self, arguments: Sequence[str], *, check: bool = True, capture_output: bool = True, text: bool = True, ) -> subprocess.CompletedProcess[str]: try: result = self._run( [*self.base_command(), *arguments], capture_output=capture_output, text=text, check=False, ) except OSError as error: raise HubError(f"could not run tmux: {error}") from error if check and result.returncode != 0: detail = (result.stderr or result.stdout or "tmux command failed").strip() raise HubError(detail) return result def exists(self) -> bool: result = self.run( ["has-session", "-t", self.session_name], check=False, ) return result.returncode == 0 def ensure_server(self) -> bool: if self.exists(): self.configure() return False bootstrap = shlex.join(["sleep", "infinity"]) self.run( [ "new-session", "-d", "-s", self.session_name, "-n", BOOTSTRAP_WINDOW_NAME, bootstrap, ] ) self.configure() return True def configure(self) -> None: option_commands = [ ["set-option", "-s", "set-clipboard", "on"], ["set-option", "-g", "prefix", "C-a"], ["set-option", "-g", "status", "on"], ["set-option", "-g", "status-position", "top"], ["set-option", "-g", "status-interval", "2"], ["set-option", "-g", "mouse", "on"], ["set-option", "-g", "focus-events", "on"], ["set-option", "-g", "status-left-length", "18"], ["set-option", "-g", "status-right-length", "42"], [ "set-option", "-g", "status-left", "#{?#{<:#{client_width},50}," "#[bold,fg=colour81] S #[default]," "#[bold,fg=colour81] Sessions #[default]}", ], [ "set-option", "-g", "status-right", "#{?#{<:#{client_width},80},," "#[dim]#{?pane_dead,连接已断开 · C-a R 重连," "C-a g choose · & close · r refresh}#[default]}", ], ["set-option", "-g", "status-style", "bg=colour235,fg=colour250"], ["set-window-option", "-g", "window-status-separator", ""], [ "set-window-option", "-g", "window-status-format", "#{?#{<:#{client_width},80}," " #{?pane_dead,×,○} #{=12:@herdr_hub_short_label} ," " #{?pane_dead,× #W [断开],○ #W} }", ], [ "set-window-option", "-g", "window-status-current-format", "#[bold,bg=colour31,fg=colour231]" "#{?#{<:#{client_width},80}," " #{?pane_dead,×,●} #{=12:@herdr_hub_short_label} ," " #{?pane_dead,× #W [断开],● #W} }" "#[default]", ], ["set-window-option", "-g", "automatic-rename", "off"], ["set-window-option", "-g", "allow-rename", "off"], ["set-window-option", "-g", "remain-on-exit", "on"], [ "set-window-option", "-g", "remain-on-exit-format", "#[bold,fg=colour196]连接已断开#[default]" "(退出状态 #{pane_dead_status}) · 按 Ctrl+A R 重连", ], ] for command in option_commands: self.run(command) refresh = shlex.join([self.python_bin, self.script_path, "refresh"]) choose = shlex.join([self.python_bin, self.script_path, "choose"]) reconnect = shlex.join([self.python_bin, self.script_path, "reconnect"]) close = shlex.join( [self.python_bin, self.script_path, "close", "--window", "#{window_id}"] ) confirm_close = shlex.join(["run-shell", "-b", close]) binding_commands = [ ["bind-key", "-T", "prefix", "C-a", "send-prefix"], ["bind-key", "-T", "prefix", "r", "run-shell", "-b", refresh], [ "bind-key", "-T", "prefix", "g", "display-popup", "-E", "-w", "90%", "-h", "90%", "-T", "Sessions", choose, ], [ "bind-key", "-T", "root", "MouseDown1StatusLeft", "display-popup", "-E", "-w", "90%", "-h", "90%", "-T", "Sessions", choose, ], ["bind-key", "-T", "prefix", "R", "run-shell", "-b", reconnect], [ "bind-key", "-T", "prefix", "&", "confirm-before", "-p", "关闭当前 Session Tab? (y/n)", confirm_close, ], ] for command in binding_commands: self.run(command) self.refresh_short_labels() def managed_windows(self) -> list[ManagedWindow]: if not self.exists(): return [] line_format = ( f"#{{window_id}}\t#{{{WINDOW_KEY_OPTION}}}\t#{{window_name}}\t" f"#{{pane_dead}}\t#{{window_active}}\t#{{{WINDOW_ENDPOINT_OPTION}}}\t" f"#{{{WINDOW_COMMAND_OPTION}}}" ) result = self.run( ["list-windows", "-t", self.session_name, "-F", line_format] ) windows: list[ManagedWindow] = [] for line in result.stdout.splitlines(): fields = line.split("\t", 6) if len(fields) != 7 or not fields[1]: continue windows.append( ManagedWindow( window_id=fields[0], key=fields[1], name=fields[2], dead=fields[3] == "1", active=fields[4] == "1", endpoint=deserialize_endpoint(fields[5], fields[6]), ) ) return windows def window_for_key(self, key: str) -> ManagedWindow | None: return next((window for window in self.managed_windows() if window.key == key), None) def refresh_short_labels(self) -> None: for window in self.managed_windows(): label = ( window.endpoint.short_label if window.endpoint is not None else truncate_label(window.name, 32) ) self.run( [ "set-option", "-w", "-t", window.window_id, WINDOW_SHORT_LABEL_OPTION, label, ] ) def dismissed_keys(self) -> set[str]: result = self.run( [ "show-options", "-v", "-t", self.session_name, SESSION_DISMISSED_OPTION, ], check=False, ) if result.returncode != 0 or not result.stdout.strip(): return set() try: payload = json.loads(result.stdout) except json.JSONDecodeError: return set() if not isinstance(payload, list): return set() return {key for key in payload if isinstance(key, str) and key} def set_dismissed_keys(self, keys: Iterable[str]) -> None: value = json.dumps(sorted(set(keys)), ensure_ascii=False, separators=(",", ":")) self.run( [ "set-option", "-t", self.session_name, SESSION_DISMISSED_OPTION, value, ] ) def restore_endpoint(self, key: str) -> None: dismissed = self.dismissed_keys() if key not in dismissed: return dismissed.remove(key) self.set_dismissed_keys(dismissed) def create_window(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow: existing = self.window_for_key(endpoint.key) if existing is not None: return existing self.restore_endpoint(endpoint.key) command = "exec " + shlex.join(endpoint.command(herdr_bin)) result = self.run( [ "new-window", "-d", "-P", "-F", "#{window_id}", "-t", f"{self.session_name}:", "-n", endpoint.label, command, ] ) window_id = result.stdout.strip() if not window_id: raise HubError("tmux did not return the new window id") for option, value in [ (WINDOW_KEY_OPTION, endpoint.key), (WINDOW_COMMAND_OPTION, command), (WINDOW_LABEL_OPTION, endpoint.label), (WINDOW_ENDPOINT_OPTION, serialize_endpoint(endpoint)), (WINDOW_SHORT_LABEL_OPTION, endpoint.short_label), ]: self.run(["set-option", "-w", "-t", window_id, option, value]) self.run(["set-window-option", "-t", window_id, "remain-on-exit", "on"]) return ManagedWindow( window_id=window_id, key=endpoint.key, name=endpoint.label, dead=False, active=False, endpoint=endpoint, ) def add_missing(self, endpoints: Sequence[Endpoint], herdr_bin: str) -> list[ManagedWindow]: existing_keys = {window.key for window in self.managed_windows()} dismissed_keys = self.dismissed_keys() added: list[ManagedWindow] = [] for endpoint in endpoints: if endpoint.key in existing_keys or endpoint.key in dismissed_keys: continue window = self.create_window(endpoint, herdr_bin) existing_keys.add(endpoint.key) added.append(window) if self.managed_windows(): self.remove_bootstrap() return added def remove_bootstrap(self) -> None: result = self.run( [ "list-windows", "-t", self.session_name, "-F", "#{window_id}\t#{window_name}", ], check=False, ) if result.returncode != 0: return for line in result.stdout.splitlines(): fields = line.split("\t", 1) if len(fields) == 2 and fields[1] == BOOTSTRAP_WINDOW_NAME: self.run(["kill-window", "-t", fields[0]], check=False) def select_window(self, window_id: str) -> None: self.run(["select-window", "-t", window_id]) def dismiss_window(self, window_id: str | None = None) -> ManagedWindow: window_id = window_id or self.current_window_id() windows = self.managed_windows() window = next((item for item in windows if item.window_id == window_id), None) if window is None or window.endpoint is None: raise HubError("the selected window is not managed by herdr-hub") if len(windows) <= 1: raise HubError("至少保留一个 Session Tab;可用 herdr-hub stop 退出整个 Hub") previous = self.dismissed_keys() self.set_dismissed_keys({*previous, window.key}) result = self.run(["kill-window", "-t", window.window_id], check=False) if result.returncode != 0: self.set_dismissed_keys(previous) detail = (result.stderr or result.stdout or "tmux command failed").strip() raise HubError(detail) return window def select_primary(self, endpoints: Sequence[Endpoint]) -> None: preferred = next( (endpoint for endpoint in endpoints if endpoint.key == Endpoint.local().key), endpoints[0] if endpoints else None, ) if preferred is None: return window = self.window_for_key(preferred.key) if window is not None: self.select_window(window.window_id) def current_window_id(self) -> str: arguments = ["display-message", "-p"] if pane_id := os.environ.get("TMUX_PANE"): arguments.extend(["-t", pane_id]) arguments.append("#{window_id}") result = self.run(arguments) window_id = result.stdout.strip() if not window_id: raise HubError("could not identify the current hub window") return window_id def reconnect(self, window_id: str | None = None) -> None: window_id = window_id or self.current_window_id() command_result = self.run( ["show-options", "-w", "-v", "-t", window_id, WINDOW_COMMAND_OPTION], check=False, ) command = command_result.stdout.strip() if command_result.returncode != 0 or not command: raise HubError("the selected window is not managed by herdr-hub") self.run(["respawn-window", "-k", "-t", window_id, command]) def activate_endpoint(self, endpoint: Endpoint, herdr_bin: str) -> ManagedWindow: window = self.window_for_key(endpoint.key) if window is None: window = self.create_window(endpoint, herdr_bin) self.remove_bootstrap() elif window.dead: self.reconnect(window.window_id) self.select_window(window.window_id) return window def attach(self) -> None: if inside_any_tmux(): if inside_socket(self.socket_name): return raise HubError( "already inside another tmux server; detach from it before attaching to herdr-hub" ) result = self.run( ["attach-session", "-t", self.session_name], check=False, capture_output=False, text=True, ) if result.returncode != 0: raise HubError("could not attach to the herdr-hub tmux session") def display_message(self, message: str) -> None: self.run(["display-message", message], check=False) def stop(self) -> None: if not self.exists(): return self.run(["kill-server"]) def inside_any_tmux() -> bool: return bool(os.environ.get("TMUX")) def inside_socket(socket_name: str) -> bool: value = os.environ.get("TMUX", "") socket_path = value.split(",", 1)[0] return bool(socket_path) and Path(socket_path).name == socket_name def resolve_binary(name: str, override: str | None = None) -> str: if override: return override resolved = shutil.which(name) if resolved is None: raise HubError(f"required executable not found on PATH: {name}") return resolved def invocation_script_path() -> str: invoked = Path(sys.argv[0]).absolute() if invoked.name == "__main__.py": repository_launcher = Path(__file__).resolve().parents[1] / "bin" / "herdr-hub" return str(repository_launcher) return str(invoked) def make_hub(arguments: argparse.Namespace) -> TmuxHub: tmux_bin = resolve_binary("tmux", os.environ.get("HERDR_HUB_TMUX_BIN")) return TmuxHub( tmux_bin=tmux_bin, socket_name=os.environ.get("HERDR_HUB_SOCKET", DEFAULT_SOCKET_NAME), session_name=os.environ.get("HERDR_HUB_SESSION", DEFAULT_TMUX_SESSION), config_file=os.environ.get("HERDR_HUB_TMUX_CONFIG"), script_path=invocation_script_path(), ) def resolve_herdr() -> str: return resolve_binary("herdr", os.environ.get("HERDR_HUB_HERDR_BIN")) def resolve_ssh() -> str: return resolve_binary("ssh", os.environ.get("HERDR_HUB_SSH_BIN")) def configured_proc_root() -> Path: return Path(os.environ.get("HERDR_HUB_PROC_ROOT", "/proc")) def make_registry() -> RemoteRegistry: return RemoteRegistry(default_registry_path()) def remember_remote_endpoints(endpoints: Iterable[Endpoint]) -> str | None: entries = [ RemoteEntry(endpoint.target, endpoint.remote_keybindings) for endpoint in endpoints if endpoint.kind == "remote" and endpoint.target is not None ] if not entries: return None try: make_registry().upsert_many(entries) except RegistryError as error: return f"remote registry: {error}" return None def catalog_windows(windows: Iterable[ManagedWindow]) -> list[CatalogWindow]: return [ CatalogWindow( window_id=window.window_id, key=window.key, label=window.name, dead=window.dead, active=window.active, kind=window.endpoint.kind if window.endpoint is not None else None, target=window.endpoint.target if window.endpoint is not None else None, session=window.endpoint.session if window.endpoint is not None else None, ) for window in windows ] def command_start(arguments: argparse.Namespace) -> int: herdr_bin = resolve_herdr() report = discover_endpoints(herdr_bin, configured_proc_root()) if warning := remember_remote_endpoints(report.endpoints): report.warnings.append(warning) hub = make_hub(arguments) created = hub.ensure_server() added = hub.add_missing(report.endpoints, herdr_bin) windows = hub.managed_windows() if not windows: if created: hub.stop() detail = "; ".join(report.warnings) suffix = f" ({detail})" if detail else "" raise HubError( "no running Herdr sessions or live --remote clients were found; " f"use 'herdr-hub open' to create one{suffix}" ) if created: hub.select_primary(report.endpoints) if added and not created: hub.display_message(f"herdr-hub: added {len(added)} session(s)") hub.attach() return 0 def command_refresh(arguments: argparse.Namespace) -> int: herdr_bin = resolve_herdr() report = discover_endpoints(herdr_bin, configured_proc_root()) if warning := remember_remote_endpoints(report.endpoints): report.warnings.append(warning) hub = make_hub(arguments) hub.ensure_server() added = hub.add_missing(report.endpoints, herdr_bin) message = f"herdr-hub: added {len(added)} session(s)" if report.warnings: message += f"; {report.warnings[0]}" if inside_socket(hub.socket_name): hub.display_message(message) else: print(message) return 0 def command_open(arguments: argparse.Namespace) -> int: herdr_bin = resolve_herdr() try: endpoint = ( Endpoint.remote( arguments.remote, arguments.session, arguments.remote_keybindings, ) if arguments.remote is not None else Endpoint.local(arguments.session) ) except ValueError as error: raise HubError(str(error)) from error if endpoint.kind == "remote": warning = remember_remote_endpoints([endpoint]) if warning: print(f"herdr-hub: warning: {warning}", file=sys.stderr) hub = make_hub(arguments) hub.ensure_server() hub.activate_endpoint(endpoint, herdr_bin) hub.attach() return 0 def command_choose(arguments: argparse.Namespace) -> int: hub = make_hub(arguments) if not hub.exists(): raise HubError("herdr-hub is not running") windows = hub.managed_windows() local_report = discover_local_sessions(resolve_herdr()) remember_remote_endpoints( window.endpoint for window in windows if window.endpoint is not None ) try: selector = SessionSelector( catalog_windows(windows), make_registry(), resolve_ssh(), local_sessions=(endpoint.session for endpoint in local_report.endpoints), ) result = selector.run() except (RegistryError, SelectorError) as error: raise HubError(str(error)) from error if result is None: return 0 if result.kind == "close": assert result.window_id is not None hub.dismiss_window(result.window_id) return 0 if result.kind == "window": assert result.window_id is not None if result.dead: hub.reconnect(result.window_id) hub.select_window(result.window_id) return 0 if result.kind == "local": assert result.session is not None hub.activate_endpoint(Endpoint.local(result.session), resolve_herdr()) return 0 assert result.target is not None and result.session is not None endpoint = Endpoint.remote( result.target, result.session, result.remote_keybindings, ) hub.activate_endpoint(endpoint, resolve_herdr()) return 0 def command_remote(arguments: argparse.Namespace) -> int: registry = make_registry() action = arguments.remote_command if action == "list": entries = registry.load() if not entries: print("no registered remote targets") return 0 for entry in entries: print(f"{entry.target}\t{entry.remote_keybindings}") return 0 if action == "add": entry = RemoteEntry(arguments.target, arguments.remote_keybindings) changed = registry.upsert(entry) print(f"{'registered' if changed else 'already registered'} {entry.target}") return 0 if action == "remove": removed = registry.remove(arguments.target) print(f"{'removed' if removed else 'not registered'} {arguments.target}") return 0 raise HubError("use 'herdr-hub remote list|add|remove'") def command_reconnect(arguments: argparse.Namespace) -> int: hub = make_hub(arguments) if not hub.exists(): raise HubError("herdr-hub is not running") hub.reconnect(arguments.window) if inside_socket(hub.socket_name): hub.display_message("herdr-hub: reconnecting current session") return 0 def command_close(arguments: argparse.Namespace) -> int: hub = make_hub(arguments) if not hub.exists(): raise HubError("herdr-hub is not running") try: window = hub.dismiss_window(arguments.window) except HubError as error: if inside_socket(hub.socket_name): hub.display_message(f"herdr-hub: {error}") return 1 raise message = f"herdr-hub: closed {window.name}; Herdr Session is still running" if inside_socket(hub.socket_name): hub.display_message(message) else: print(message) return 0 def command_stop(arguments: argparse.Namespace) -> int: hub = make_hub(arguments) if hub.exists(): hub.stop() print("stopped herdr-hub; Herdr servers were left running") else: print("herdr-hub is not running") return 0 def command_doctor(arguments: argparse.Namespace) -> int: failures = 0 print(f"python: {sys.version.split()[0]} ({sys.executable})") print(f"curses: {getattr(curses, 'version', 'available')}") for binary in ["tmux", "herdr", "ssh"]: override = os.environ.get(f"HERDR_HUB_{binary.upper()}_BIN") path = shutil.which(binary) if override is None else override if path is None: print(f"{binary}: missing") failures += 1 continue version_args = [path, "-V"] if binary in {"tmux", "ssh"} else [path, "--version"] result = subprocess.run( version_args, capture_output=True, text=True, check=False, ) version = (result.stdout or result.stderr).strip() print(f"{binary}: {version or path}") registry = make_registry() try: registered = registry.load() print(f"remotes: {len(registered)} registered ({registry.path})") except RegistryError as error: print(f"remotes: invalid ({error})") failures += 1 proc_root = configured_proc_root() print(f"proc: {proc_root} ({'available' if proc_root.is_dir() else 'missing'})") try: herdr_bin = resolve_herdr() except HubError as error: print(f"discovery: unavailable ({error})") return 1 report = discover_endpoints(herdr_bin, proc_root) print(f"discovered: {len(report.endpoints)} endpoint(s)") for endpoint in report.endpoints: print(f" {endpoint.label} [{endpoint.key}]") for warning in report.warnings: print(f"warning: {warning}") return failures def build_parser() -> argparse.ArgumentParser: parser = argparse.ArgumentParser( prog="herdr-hub", description="Switch local and live --remote Herdr sessions through a tmux top bar.", ) subparsers = parser.add_subparsers(dest="command") subparsers.add_parser("start", help="scan, create and attach to the hub") subparsers.add_parser("refresh", help="add newly discovered sessions") open_parser = subparsers.add_parser("open", help="open a session directly in the hub") open_parser.add_argument("--remote", metavar="TARGET") open_parser.add_argument("--session", default=DEFAULT_SESSION_NAME) open_parser.add_argument( "--remote-keybindings", choices=["local", "server"], default="local", ) subparsers.add_parser("choose", help="browse and open local or remote sessions") remote_parser = subparsers.add_parser("remote", help="manage remembered remote targets") remote_subparsers = remote_parser.add_subparsers(dest="remote_command") remote_subparsers.add_parser("list", help="list remembered remote targets") remote_add_parser = remote_subparsers.add_parser("add", help="remember a remote target") remote_add_parser.add_argument("target", metavar="TARGET") remote_add_parser.add_argument( "--remote-keybindings", choices=["local", "server"], default="local", ) remote_remove_parser = remote_subparsers.add_parser( "remove", help="forget a remote target" ) remote_remove_parser.add_argument("target", metavar="TARGET") reconnect_parser = subparsers.add_parser( "reconnect", help="respawn a dead managed window" ) reconnect_parser.add_argument("--window") close_parser = subparsers.add_parser( "close", help="hide a managed window without stopping its Herdr session" ) close_parser.add_argument("--window") subparsers.add_parser("doctor", help="check dependencies and discovery") subparsers.add_parser("stop", help="stop the hub clients, leaving Herdr servers running") return parser def main(argv: Sequence[str] | None = None) -> int: parser = build_parser() arguments = parser.parse_args(argv) command = arguments.command or "start" handlers = { "start": command_start, "refresh": command_refresh, "open": command_open, "choose": command_choose, "remote": command_remote, "reconnect": command_reconnect, "close": command_close, "doctor": command_doctor, "stop": command_stop, } try: return handlers[command](arguments) except (HubError, RegistryError, ValueError) as error: print(f"herdr-hub: {error}", file=sys.stderr) return 1 except KeyboardInterrupt: return 130 if __name__ == "__main__": raise SystemExit(main())