cli.py
1 """CLI commands for the google_meet plugin. 2 3 Wires ``hermes meet <subcommand>``: 4 setup — preflight playwright, chromium, auth file, print fixes 5 auth — open a browser to sign into Google, save storage state 6 join <url> — join a Meet URL synchronously (also callable from the agent) 7 status — print current bot state 8 transcript — print the transcript 9 stop — leave the current meeting 10 """ 11 12 from __future__ import annotations 13 14 import argparse 15 import json 16 import os 17 import sys 18 from pathlib import Path 19 from typing import Optional 20 21 from hermes_constants import get_hermes_home 22 23 from plugins.google_meet import process_manager as pm 24 from plugins.google_meet.meet_bot import _is_safe_meet_url 25 26 27 def _auth_state_path() -> Path: 28 return Path(get_hermes_home()) / "workspace" / "meetings" / "auth.json" 29 30 31 # --------------------------------------------------------------------------- 32 # argparse wiring 33 # --------------------------------------------------------------------------- 34 35 def register_cli(subparser: argparse.ArgumentParser) -> None: 36 """Build the ``hermes meet`` argparse tree. 37 38 Called by :func:`_register_cli_commands` at plugin load time. 39 """ 40 subs = subparser.add_subparsers(dest="meet_command") 41 42 subs.add_parser("setup", help="Preflight: playwright, chromium, auth") 43 44 inst_p = subs.add_parser( 45 "install", 46 help="Install prerequisites (pip deps, Chromium, platform audio tools)", 47 ) 48 inst_p.add_argument( 49 "--realtime", action="store_true", 50 help="Also install realtime audio tools (pulseaudio-utils on Linux, BlackHole+ffmpeg on macOS). Uses sudo/brew, prompts before invoking either.", 51 ) 52 inst_p.add_argument( 53 "--yes", "-y", action="store_true", 54 help="Answer yes to all prompts (use with care; will run sudo apt-get or brew without asking).", 55 ) 56 57 subs.add_parser("auth", help="Sign in to Google and save session state") 58 59 join_p = subs.add_parser("join", help="Join a Meet URL") 60 join_p.add_argument("url", help="https://meet.google.com/...") 61 join_p.add_argument("--guest-name", default="Hermes Agent") 62 join_p.add_argument("--duration", default=None, help="e.g. 30m, 2h, 90s") 63 join_p.add_argument("--headed", action="store_true", help="show browser") 64 join_p.add_argument( 65 "--mode", choices=("transcribe", "realtime"), default="transcribe", 66 help="transcribe (default, listen-only) or realtime (speak via OpenAI Realtime)" 67 ) 68 join_p.add_argument( 69 "--node", default=None, 70 help="remote node name, or 'auto' to use the sole registered node" 71 ) 72 73 subs.add_parser("status", help="Print current Meet bot state") 74 75 tr_p = subs.add_parser("transcript", help="Print the scraped transcript") 76 tr_p.add_argument("--last", type=int, default=None) 77 78 say_p = subs.add_parser("say", help="Speak text in an active realtime meeting") 79 say_p.add_argument("text", help="what to say") 80 say_p.add_argument("--node", default=None) 81 82 subs.add_parser("stop", help="Leave the current meeting") 83 84 # v3: remote node host management. 85 node_p = subs.add_parser( 86 "node", 87 help="Manage remote meet node hosts (run/list/approve/remove/status/ping)", 88 ) 89 try: 90 from plugins.google_meet.node.cli import register_cli as _register_node_cli 91 _register_node_cli(node_p) 92 except Exception as e: # pragma: no cover — defensive 93 # If the node module fails to import for any reason (optional dep 94 # missing at import time etc.), leave the subparser present but 95 # flag it. The argparse dispatch will surface a clear error. 96 def _node_unavailable(args): 97 print(f"hermes meet node: module unavailable ({e})") 98 return 1 99 node_p.set_defaults(func=_node_unavailable) 100 101 subparser.set_defaults(func=meet_command) 102 103 104 # --------------------------------------------------------------------------- 105 # Dispatch 106 # --------------------------------------------------------------------------- 107 108 def meet_command(args: argparse.Namespace) -> int: 109 sub = getattr(args, "meet_command", None) 110 if not sub: 111 print("usage: hermes meet {setup,auth,join,status,transcript,say,stop,node}") 112 return 2 113 if sub == "setup": 114 return _cmd_setup() 115 if sub == "install": 116 return _cmd_install( 117 realtime=bool(getattr(args, "realtime", False)), 118 assume_yes=bool(getattr(args, "yes", False)), 119 ) 120 if sub == "auth": 121 return _cmd_auth() 122 if sub == "join": 123 return _cmd_join( 124 url=args.url, 125 guest_name=args.guest_name, 126 duration=args.duration, 127 headed=args.headed, 128 mode=getattr(args, "mode", "transcribe"), 129 node=getattr(args, "node", None), 130 ) 131 if sub == "status": 132 return _cmd_status() 133 if sub == "transcript": 134 return _cmd_transcript(last=args.last) 135 if sub == "say": 136 return _cmd_say(text=args.text, node=getattr(args, "node", None)) 137 if sub == "stop": 138 return _cmd_stop() 139 if sub == "node": 140 # Dispatch was set by the node cli's register_cli; fall through to 141 # whatever its subparsers wired. 142 fn = getattr(args, "func", None) 143 if fn is None or fn is meet_command: 144 print("usage: hermes meet node {run,list,approve,remove,status,ping}") 145 return 2 146 return fn(args) 147 print(f"unknown subcommand: {sub}") 148 return 2 149 150 151 # --------------------------------------------------------------------------- 152 # Subcommand handlers 153 # --------------------------------------------------------------------------- 154 155 def _cmd_setup() -> int: 156 import platform as _p 157 158 print("google_meet preflight") 159 print("---------------------") 160 161 system = _p.system() 162 system_ok = system in ("Linux", "Darwin") 163 print(f" platform : {system} [{'ok' if system_ok else 'unsupported'}]") 164 165 try: 166 import playwright # noqa: F401 167 pw_ok = True 168 pw_msg = "installed" 169 except ImportError: 170 pw_ok = False 171 pw_msg = "NOT installed — run: pip install playwright" 172 print(f" playwright : {pw_msg}") 173 174 chromium_ok = False 175 chromium_msg = "unknown" 176 if pw_ok: 177 try: 178 from playwright.sync_api import sync_playwright 179 with sync_playwright() as p: 180 try: 181 exe = p.chromium.executable_path 182 if exe and Path(exe).exists(): 183 chromium_ok = True 184 chromium_msg = f"ok ({exe})" 185 else: 186 chromium_msg = ( 187 "not installed — run: " 188 "python -m playwright install chromium" 189 ) 190 except Exception as e: 191 chromium_msg = f"probe failed: {e}" 192 except Exception as e: 193 chromium_msg = f"probe failed: {e}" 194 print(f" chromium : {chromium_msg}") 195 196 auth_path = _auth_state_path() 197 auth_ok = auth_path.is_file() 198 print( 199 " google auth : " 200 + (f"ok ({auth_path})" if auth_ok else "not saved — run: hermes meet auth") 201 ) 202 203 print() 204 all_ok = system_ok and pw_ok and chromium_ok 205 if all_ok: 206 print( 207 "ready. Join a meeting: " 208 "hermes meet join https://meet.google.com/abc-defg-hij" 209 ) 210 else: 211 print("not ready yet — fix the items above.") 212 return 0 if all_ok else 1 213 214 215 def _cmd_install(*, realtime: bool, assume_yes: bool) -> int: 216 """Install the plugin's prerequisites. 217 218 Always: pip install playwright + websockets, then 219 ``python -m playwright install chromium``. 220 221 With ``--realtime``: also install the platform audio bridge deps. 222 Linux : ``sudo apt-get install -y pulseaudio-utils`` 223 macOS : ``brew install blackhole-2ch ffmpeg`` (+ remind the user 224 to select BlackHole as the default input device manually) 225 226 Prompts before every package-manager invocation unless ``--yes``. 227 Refuses to run on Windows. 228 """ 229 import platform as _p 230 import shutil as _shutil 231 import subprocess as _sp 232 233 system = _p.system() 234 if system not in ("Linux", "Darwin"): 235 print(f"google_meet install: {system} is not supported (linux/macos only)") 236 return 1 237 238 def _confirm(prompt: str) -> bool: 239 if assume_yes: 240 return True 241 try: 242 ans = input(f"{prompt} [y/N] ").strip().lower() 243 except EOFError: 244 return False 245 return ans in ("y", "yes") 246 247 print("google_meet install") 248 print("-------------------") 249 250 # 1) pip deps — always safe, venv-scoped. 251 pip_pkgs = ["playwright", "websockets"] 252 print(f"\n[1/3] pip install: {' '.join(pip_pkgs)}") 253 try: 254 res = _sp.run( 255 [sys.executable, "-m", "pip", "install", "--upgrade", *pip_pkgs], 256 check=False, 257 ) 258 if res.returncode != 0: 259 print(" pip install failed") 260 return 1 261 except Exception as e: 262 print(f" pip install failed: {e}") 263 return 1 264 265 # 2) Playwright browsers — pulls chromium (~300MB first run). 266 print("\n[2/3] python -m playwright install chromium") 267 try: 268 res = _sp.run( 269 [sys.executable, "-m", "playwright", "install", "chromium"], 270 check=False, 271 ) 272 if res.returncode != 0: 273 print(" playwright install failed (may already be installed)") 274 except Exception as e: 275 print(f" playwright install failed: {e}") 276 return 1 277 278 # 3) Platform audio deps for realtime mode. 279 if realtime: 280 print("\n[3/3] realtime audio deps") 281 if system == "Linux": 282 if _shutil.which("paplay") and _shutil.which("pactl"): 283 print(" pulseaudio-utils already installed.") 284 else: 285 if not _confirm( 286 " install pulseaudio-utils? this runs `sudo apt-get install -y pulseaudio-utils`" 287 ): 288 print(" skipped (you can run it manually later)") 289 else: 290 cmd = ["sudo", "apt-get", "install", "-y", "pulseaudio-utils"] 291 print(f" $ {' '.join(cmd)}") 292 res = _sp.run(cmd, check=False) 293 if res.returncode != 0: 294 print(" apt install failed — install pulseaudio-utils manually") 295 elif system == "Darwin": 296 have_bh = False 297 try: 298 out = _sp.check_output(["system_profiler", "SPAudioDataType"], text=True) 299 have_bh = "BlackHole" in out 300 except Exception: 301 pass 302 have_ffmpeg = bool(_shutil.which("ffmpeg")) 303 needs = [] 304 if not have_bh: 305 needs.append("blackhole-2ch") 306 if not have_ffmpeg: 307 needs.append("ffmpeg") 308 if not needs: 309 print(" BlackHole and ffmpeg already installed.") 310 elif not _shutil.which("brew"): 311 print( 312 " missing: " + ", ".join(needs) + "\n" 313 " install Homebrew first (https://brew.sh) or install the packages manually." 314 ) 315 else: 316 if not _confirm(f" install via brew: {' '.join(needs)}?"): 317 print(" skipped (you can run it manually later)") 318 else: 319 cmd = ["brew", "install", *needs] 320 print(f" $ {' '.join(cmd)}") 321 res = _sp.run(cmd, check=False) 322 if res.returncode != 0: 323 print(" brew install failed — install them manually") 324 print( 325 "\n NOTE: macOS does not auto-route audio. Open\n" 326 " System Settings → Sound → Input\n" 327 " and select 'BlackHole 2ch' before starting a realtime meeting.\n" 328 " hermes will not switch your default input for you." 329 ) 330 else: 331 print("\n[3/3] skipped (pass --realtime to install audio tooling too)") 332 333 print("\ndone. verify with: hermes meet setup") 334 return 0 335 336 337 def _cmd_auth() -> int: 338 """Open a headed Chromium, let the user sign in, save storage_state.""" 339 try: 340 from playwright.sync_api import sync_playwright 341 except ImportError: 342 print( 343 "playwright is not installed. run:\n" 344 " pip install playwright && python -m playwright install chromium" 345 ) 346 return 1 347 348 path = _auth_state_path() 349 path.parent.mkdir(parents=True, exist_ok=True) 350 351 print(f"opening Chromium — sign in to Google, then return here and press Enter.") 352 print(f"saving storage state to: {path}") 353 try: 354 with sync_playwright() as pw: 355 browser = pw.chromium.launch(headless=False) 356 context = browser.new_context() 357 page = context.new_page() 358 page.goto("https://accounts.google.com/", wait_until="domcontentloaded") 359 try: 360 input("press Enter after you've signed in ... ") 361 except EOFError: 362 pass 363 context.storage_state(path=str(path)) 364 browser.close() 365 except Exception as e: 366 print(f"auth failed: {e}") 367 return 1 368 print("saved. you can now run: hermes meet join <url>") 369 return 0 370 371 372 def _cmd_join( 373 url: str, 374 *, 375 guest_name: str, 376 duration: Optional[str], 377 headed: bool, 378 mode: str = "transcribe", 379 node: Optional[str] = None, 380 ) -> int: 381 if not _is_safe_meet_url(url): 382 print(f"refusing: not a meet.google.com URL: {url}") 383 return 2 384 if node: 385 # Remote: go through NodeClient. 386 try: 387 from plugins.google_meet.node.registry import NodeRegistry 388 from plugins.google_meet.node.client import NodeClient 389 except ImportError as e: 390 print(f"node module unavailable: {e}") 391 return 1 392 reg = NodeRegistry() 393 entry = reg.resolve(node if node != "auto" else None) 394 if entry is None: 395 print(f"no registered node matches {node!r}") 396 return 1 397 client = NodeClient(url=entry["url"], token=entry["token"]) 398 try: 399 res = client.start_bot( 400 url=url, guest_name=guest_name, duration=duration, 401 headed=headed, mode=mode, 402 ) 403 except Exception as e: 404 print(f"remote start_bot failed: {e}") 405 return 1 406 print(json.dumps({"node": entry.get("name"), **res}, indent=2)) 407 return 0 if res.get("ok") else 1 408 409 auth = _auth_state_path() 410 res = pm.start( 411 url=url, 412 headed=headed, 413 guest_name=guest_name, 414 duration=duration, 415 auth_state=str(auth) if auth.is_file() else None, 416 mode=mode, 417 ) 418 print(json.dumps(res, indent=2)) 419 return 0 if res.get("ok") else 1 420 421 422 def _cmd_say(text: str, node: Optional[str] = None) -> int: 423 if not (text or "").strip(): 424 print("refusing: empty text") 425 return 2 426 if node: 427 try: 428 from plugins.google_meet.node.registry import NodeRegistry 429 from plugins.google_meet.node.client import NodeClient 430 except ImportError as e: 431 print(f"node module unavailable: {e}") 432 return 1 433 reg = NodeRegistry() 434 entry = reg.resolve(node if node != "auto" else None) 435 if entry is None: 436 print(f"no registered node matches {node!r}") 437 return 1 438 client = NodeClient(url=entry["url"], token=entry["token"]) 439 try: 440 res = client.say(text) 441 except Exception as e: 442 print(f"remote say failed: {e}") 443 return 1 444 print(json.dumps({"node": entry.get("name"), **res}, indent=2)) 445 return 0 if res.get("ok") else 1 446 447 res = pm.enqueue_say(text) 448 print(json.dumps(res, indent=2)) 449 return 0 if res.get("ok") else 1 450 451 452 def _cmd_status() -> int: 453 res = pm.status() 454 print(json.dumps(res, indent=2)) 455 return 0 if res.get("ok") else 1 456 457 458 def _cmd_transcript(last: Optional[int]) -> int: 459 res = pm.transcript(last=last) 460 if not res.get("ok"): 461 print(json.dumps(res, indent=2)) 462 return 1 463 for ln in res.get("lines", []): 464 print(ln) 465 return 0 466 467 468 def _cmd_stop() -> int: 469 res = pm.stop(reason="hermes meet stop") 470 print(json.dumps(res, indent=2)) 471 return 0 if res.get("ok") else 1 472 473 474 if __name__ == "__main__": # pragma: no cover 475 parser = argparse.ArgumentParser(prog="hermes meet") 476 register_cli(parser) 477 ns = parser.parse_args() 478 sys.exit(meet_command(ns))