""" meshbay-cli [global options] [action options] [arguments] With no argument at all, the interactive mode opens. """ import argparse import asyncio import logging import os import sys from . import __version__ from .commands import ( browser_access, chat, decline, devices, get, groups, help, invitations, join, leave, login, logout, ls, put, rm, search, trust, ) from .commands.context import Context from .errors import INTERRUPTED, USAGE, CliError ACTIONS = {"login": login, "logout": logout, "groups": groups, "invitations": invitations, "join": join, "decline": decline, "leave": leave, "ls": ls, "search": search, "get": get, "put": put, "rm": rm, "chat": chat, "devices": devices, "trust": trust, "browser-access": browser_access, "help": help} class _Parser(argparse.ArgumentParser): """argparse exits 2 on a usage error, which here means a refused sign-in.""" def error(self, message: str): self.print_usage(sys.stderr) self.exit(USAGE, f"{self.prog}: error: {message}\n") def _global_options(p: argparse.ArgumentParser, default) -> None: p.add_argument("--hub", default=default, help="the hub (default: the one signed in to, " "else https://meshbay.org); also MESHBAY_HUB") p.add_argument("-u", "--user", default=default, help="account name; also MESHBAY_USER") p.add_argument("-p", "--password", default=default, help="the passphrase; visible to other users in the process list and " "kept in shell history — --password-file - or MESHBAY_PASSWORD " "are not") p.add_argument("--password-file", metavar="FILE", default=default, help="read the passphrase from FILE's first line (- for stdin)") p.add_argument("--json", action="store_true", default=default or False, help="machine-readable output") p.add_argument("-q", "--quiet", action="store_true", default=default or False, help="errors only") p.add_argument("-v", "--verbose", action="count", default=default or 0, help="-v: connection steps, -vv: protocol messages") def build_parser() -> argparse.ArgumentParser: """Global options are accepted before the action and after it.""" p = _Parser( prog="meshbay-cli", description="A member's MeshBay groups and files, from a terminal. Run with no " "argument for the interactive mode.") _global_options(p, None) p.add_argument("--version", action="version", version=f"meshbay-cli {__version__}") # SUPPRESS: an option the action's own parser did not see must not # overwrite the value given before the action. late = _Parser(add_help=False) _global_options(late, argparse.SUPPRESS) sub = p.add_subparsers(dest="action", metavar="") for module in ACTIONS.values(): module.add_parser(sub, late) return p def _logging(verbose: int, quiet: bool) -> None: handler = logging.StreamHandler(sys.stderr) handler.setFormatter(logging.Formatter("meshbay-cli: %(levelname)s: %(message)s")) root = logging.getLogger() root.handlers[:] = [handler] root.setLevel(logging.ERROR if quiet else logging.WARNING) level = {0: logging.WARNING, 1: logging.INFO}.get(verbose, logging.DEBUG) logging.getLogger("meshbay_cli").setLevel(logging.ERROR if quiet else level) # aiortc and aioice are chatty at INFO; their story is told from -vv on. for name in ("aiortc", "aioice", "httpx", "httpcore"): logging.getLogger(name).setLevel(logging.DEBUG if verbose >= 3 else logging.WARNING) async def _run(args: argparse.Namespace) -> int: ctx = Context(args) try: if args.action is None: from .tui.app import run_interactive return await run_interactive(ctx) return await ACTIONS[args.action].run(ctx) finally: await ctx.aclose() def loop_factory(): """aiortc's ICE needs a selector loop: Windows' default proactor never completes a DataChannel handshake.""" return asyncio.SelectorEventLoop if sys.platform == "win32" else None def main(argv: list[str] | None = None) -> int: argv = sys.argv[1:] if argv is None else argv if not argv and os.environ.get("MESHBAY_CLI_SELFTEST") == "1": from .selftest import run return run() parser = build_parser() interactive = not argv argv = chat.rewrite_count(argv, ACTIONS) try: args = parser.parse_args(argv) except SystemExit as e: # --help, --version, or a usage error return int(e.code or 0) if args.action is None and not interactive: parser.print_usage(sys.stderr) return USAGE _logging(args.verbose, args.quiet) _utf8_output() try: return asyncio.run(_run(args), loop_factory=loop_factory()) except CliError as e: print(f"meshbay-cli: {e.message}", file=sys.stderr) return e.code except KeyboardInterrupt: print("meshbay-cli: interrupted", file=sys.stderr) return INTERRUPTED def _utf8_output() -> None: """Names are printed as they are; a Windows console's code page cannot hold most.""" for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: try: reconfigure(encoding="utf-8", errors="replace") except (ValueError, OSError): pass if __name__ == "__main__": sys.exit(main())