cli.py
cli.pyBrowse 18 files
3,063 tokens
12,599 bytes
Token encoding: o200k_base
Snapshot 24fd22b
← Back to SKILL.md
1"""CLI commands for the google_meet plugin (``hermes meet <subcommand>``).2 3 setup / install — preflight and install prerequisites4 auth — open a browser to sign into Google, save storage state5 join <url> — join a Meet URL (locally or on a remote node)6 status / transcript / say / stop — drive the active bot7 node — remote node host management (see node/cli.py)8"""9 10from __future__ import annotations11 12import argparse13import contextlib14import importlib.util15import json16import platform17import shutil18import subprocess19import sys20from pathlib import Path21from typing import Optional22 23from hermes_constants import get_hermes_home24 25from plugins.google_meet import process_manager as pm26from plugins.google_meet.meet_bot import _is_safe_meet_url27from plugins.google_meet.node.cli import node_command, register_cli as _register_node_cli28from plugins.google_meet.tools import resolve_node29 30 31def _auth_state_path() -> Path:32 return Path(get_hermes_home()) / "workspace" / "meetings" / "auth.json"33 34 35# ``hermes meet <sub>`` in help order.36_SUBCOMMAND_HELP = (37 ("setup", "Preflight: playwright, chromium, auth"),38 ("install", "Install prerequisites (pip deps, Chromium, platform audio tools)"),39 ("auth", "Sign in to Google and save session state"),40 ("join", "Join a Meet URL"),41 ("status", "Print current Meet bot state"),42 ("transcript", "Print the scraped transcript"),43 ("say", "Speak text in an active realtime meeting"),44 ("stop", "Leave the current meeting"),45 ("node", "Manage remote meet node hosts (run/list/approve/remove/status/ping)"))46 47 48def register_cli(subparser: argparse.ArgumentParser) -> None:49 """Build the ``hermes meet`` argparse tree (called at plugin load time)."""50 subs = subparser.add_subparsers(dest="meet_command")51 p = {name: subs.add_parser(name, help=help_) for name, help_ in _SUBCOMMAND_HELP}52 p["install"].add_argument("--realtime", action="store_true",53 help="Also install realtime audio tools (pulseaudio-utils on Linux, BlackHole+ffmpeg on macOS). Uses sudo/brew, prompts before invoking either.")54 p["install"].add_argument("--yes", "-y", action="store_true",55 help="Answer yes to all prompts (use with care; will run sudo apt-get or brew without asking).")56 p["join"].add_argument("url", help="https://meet.google.com/...")57 p["join"].add_argument("--guest-name", default="Hermes Agent")58 p["join"].add_argument("--duration", default=None, help="e.g. 30m, 2h, 90s")59 p["join"].add_argument("--headed", action="store_true", help="show browser")60 p["join"].add_argument("--mode", choices=("transcribe", "realtime"), default="transcribe",61 help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)")62 p["join"].add_argument("--node", default=None,63 help="remote node name, or 'auto' to use the sole registered node")64 p["transcript"].add_argument("--last", type=int, default=None)65 p["say"].add_argument("text", help="what to say")66 p["say"].add_argument("--node", default=None)67 _register_node_cli(p["node"])68 subparser.set_defaults(func=meet_command)69 70 71_DISPATCH = {72 "setup": lambda a: _cmd_setup(),73 "install": lambda a: _cmd_install(realtime=bool(a.realtime), assume_yes=bool(a.yes)),74 "auth": lambda a: _cmd_auth(),75 "join": lambda a: _cmd_join(url=a.url, guest_name=a.guest_name, duration=a.duration, headed=a.headed,76 mode=a.mode, node=a.node),77 "status": lambda a: _print_result(pm.status()),78 "transcript": lambda a: _cmd_transcript(last=a.last),79 "say": lambda a: _cmd_say(text=a.text, node=a.node),80 "stop": lambda a: _print_result(pm.stop(reason="hermes meet stop")),81 "node": node_command} # node subparsers are required=True, so a sub-command is always present82 83 84def meet_command(args: argparse.Namespace) -> int:85 sub = args.meet_command86 if not sub:87 print("usage: hermes meet {setup,auth,join,status,transcript,say,stop,node}")88 return 289 handler = _DISPATCH.get(sub)90 if handler is None:91 print(f"unknown subcommand: {sub}")92 return 293 return handler(args)94 95 96def _cmd_setup() -> int:97 print("google_meet preflight\n---------------------")98 system = platform.system()99 system_ok = system in {"Linux", "Darwin"}100 print(f" platform : {system} [{'ok' if system_ok else 'unsupported'}]")101 pw_ok = importlib.util.find_spec("playwright") is not None102 print(" playwright : " + ("installed" if pw_ok else "NOT installed — run: pip install playwright"))103 chromium_ok, chromium_msg = False, "unknown"104 if pw_ok:105 try:106 from playwright.sync_api import sync_playwright107 with sync_playwright() as p:108 exe = p.chromium.executable_path109 chromium_ok = bool(exe and Path(exe).exists())110 chromium_msg = f"ok ({exe})" if chromium_ok else "not installed — run: python -m playwright install chromium"111 except Exception as e:112 chromium_msg = f"probe failed: {e}"113 print(f" chromium : {chromium_msg}")114 auth_path = _auth_state_path()115 print(" google auth : " + (f"ok ({auth_path})" if auth_path.is_file() else "not saved — run: hermes meet auth"))116 print()117 all_ok = system_ok and pw_ok and chromium_ok118 print("ready. Join a meeting: hermes meet join https://meet.google.com/abc-defg-hij" if all_ok119 else "not ready yet — fix the items above.")120 return 0 if all_ok else 1121 122 123def _cmd_install(*, realtime: bool, assume_yes: bool) -> int:124 """pip deps + Chromium; ``--realtime`` adds the platform audio bridge deps.125 Prompts before every package-manager invocation unless ``--yes``. Linux/macOS only."""126 system = platform.system()127 if system not in {"Linux", "Darwin"}:128 print(f"google_meet install: {system} is not supported (linux/macos only)")129 return 1130 131 def _install_pkgs(prompt: str, cmd: list[str], fail_msg: str) -> None:132 """Confirm (unless --yes) then run a package-manager command, reporting failure."""133 try:134 ok = assume_yes or input(f"{prompt} [y/N] ").strip().lower() in {"y", "yes"}135 except EOFError:136 ok = False137 if not ok:138 print(" skipped (you can run it manually later)")139 return140 print(f" $ {' '.join(cmd)}")141 # noqa: subprocess-stdin — sudo/brew may prompt on the tty; user explicitly confirmed above142 if subprocess.run(cmd, check=False).returncode != 0:143 print(fail_msg)144 145 print("google_meet install\n-------------------")146 pip_pkgs = ["playwright", "websockets"]147 print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}")148 try:149 from hermes_cli.tools_config import _pip_install150 if _pip_install(["--upgrade", *pip_pkgs], capture_output=False).returncode != 0:151 print(" pip install failed")152 return 1153 except Exception as e:154 print(f" pip install failed: {e}")155 return 1156 print("\n[2/3] python -m playwright install chromium")157 try:158 if subprocess.run([sys.executable, "-m", "playwright", "install", "chromium"], check=False,159 stdin=subprocess.DEVNULL).returncode != 0:160 print(" playwright install failed (may already be installed)")161 except Exception as e:162 print(f" playwright install failed: {e}")163 return 1164 if not realtime:165 print("\n[3/3] skipped (pass --realtime to install audio tooling too)")166 else:167 print("\n[3/3] realtime audio deps")168 if system == "Linux":169 if shutil.which("paplay") and shutil.which("pactl"):170 print(" pulseaudio-utils already installed.")171 else:172 _install_pkgs(" install pulseaudio-utils? this runs `sudo apt-get install -y pulseaudio-utils`",173 ["sudo", "apt-get", "install", "-y", "pulseaudio-utils"],174 " apt install failed — install pulseaudio-utils manually")175 elif system == "Darwin":176 try:177 have_bh = "BlackHole" in subprocess.check_output(178 ["system_profiler", "SPAudioDataType"], text=True, encoding='utf-8', errors='replace',179 stdin=subprocess.DEVNULL)180 except Exception:181 have_bh = False182 needs = [pkg for pkg, have in (("blackhole-2ch", have_bh), ("ffmpeg", shutil.which("ffmpeg"))) if not have]183 if not needs:184 print(" BlackHole and ffmpeg already installed.")185 elif not shutil.which("brew"):186 print(" missing: " + ", ".join(needs) + "\n"187 " install Homebrew first (https://brew.sh) or install the packages manually.")188 else:189 _install_pkgs(f" install via brew: {' '.join(needs)}?", ["brew", "install", *needs],190 " brew install failed — install them manually")191 print("\n NOTE: macOS does not auto-route audio. Open\n System Settings → Sound → "192 "Input\n and select 'BlackHole 2ch' before starting a realtime meeting.\n "193 "hermes will not switch your default input for you.")194 print("\ndone. verify with: hermes meet setup")195 return 0196 197 198def _cmd_auth() -> int:199 """Open a headed Chromium, let the user sign in, save storage_state."""200 try:201 from playwright.sync_api import sync_playwright202 except ImportError:203 print("playwright is not installed. run:\n"204 " pip install playwright && python -m playwright install chromium")205 return 1206 path = _auth_state_path()207 path.parent.mkdir(parents=True, exist_ok=True)208 print("opening Chromium — sign in to Google, then return here and press Enter.\n"209 f"saving storage state to: {path}")210 try:211 with sync_playwright() as pw:212 browser = pw.chromium.launch(headless=False)213 context = browser.new_context()214 context.new_page().goto("https://accounts.google.com/", wait_until="domcontentloaded")215 with contextlib.suppress(EOFError):216 input("press Enter after you've signed in ... ")217 context.storage_state(path=str(path))218 browser.close()219 except Exception as e:220 print(f"auth failed: {e}")221 return 1222 print("saved. you can now run: hermes meet join <url>")223 return 0224 225 226def _print_result(res: dict) -> int:227 print(json.dumps(res, indent=2))228 return 0 if res.get("ok") else 1229 230 231def _remote(node: str, op: str, call) -> int:232 """Run *call(client)* against the registered node *node* and print the result."""233 try:234 client, name = resolve_node(node)235 except ImportError as e:236 print(f"node module unavailable: {e}")237 return 1238 if client is None:239 print(f"no registered node matches {node!r}")240 return 1241 try:242 res = call(client)243 except Exception as e:244 print(f"remote {op} failed: {e}")245 return 1246 return _print_result({"node": name, **res})247 248 249def _cmd_join(url: str, *, guest_name: str, duration: Optional[str], headed: bool,250 mode: str = "transcribe", node: Optional[str] = None) -> int:251 if not _is_safe_meet_url(url):252 print(f"refusing: not a meet.google.com URL: {url}")253 return 2254 if node:255 return _remote(node, "start_bot", lambda c: c.start_bot(256 url=url, guest_name=guest_name, duration=duration, headed=headed, mode=mode))257 auth = _auth_state_path()258 return _print_result(pm.start(url=url, headed=headed, guest_name=guest_name, duration=duration,259 auth_state=str(auth) if auth.is_file() else None, mode=mode))260 261 262def _cmd_say(text: str, node: Optional[str] = None) -> int:263 if not (text or "").strip():264 print("refusing: empty text")265 return 2266 if node:267 return _remote(node, "say", lambda c: c.say(text))268 return _print_result(pm.enqueue_say(text))269 270 271def _cmd_transcript(last: Optional[int]) -> int:272 res = pm.transcript(last=last)273 if not res.get("ok"):274 return _print_result(res)275 for ln in res.get("lines", []):276 print(ln)277 return 0278 279 280if __name__ == "__main__": # pragma: no cover281 parser = argparse.ArgumentParser(prog="hermes meet")282 register_cli(parser)283 sys.exit(meet_command(parser.parse_args()))284