From 422647e7898b8df67cd11f8fb060aec23dec84aa Mon Sep 17 00:00:00 2001 From: Christophe Besson Date: Wed, 14 Oct 2026 22:20:15 +0200 Subject: feat(cli): meshbay-cli, a command-line client for members Signs in to the hub, lists the account's groups and their folders, and downloads and uploads files directly with each group's node, end-to-end encrypted. With no argument it opens a two-pane interactive mode (Textual). Session and device keys are kept encrypted under the system's key storage, or a 0600 key file where there is none; node keys are pinned at first contact; a new machine links as a device. Packaged on its own on Linux (deb and rpm, on meshbay-common's venv, with a man page generated from the parser) and frozen next to meshbay-node in the full Windows installer, not the light one, with a self-test the build runs. client/keys.py ports keyderive.js and is LGPL like it. Co-Authored-By: Claude Opus 5.5 --- packages/meshbay-cli/src/meshbay_cli/__main__.py | 134 +++++++++++++++++++++++ 1 file changed, 134 insertions(+) create mode 100644 packages/meshbay-cli/src/meshbay_cli/__main__.py (limited to 'packages/meshbay-cli/src/meshbay_cli/__main__.py') diff --git a/packages/meshbay-cli/src/meshbay_cli/__main__.py b/packages/meshbay-cli/src/meshbay_cli/__main__.py new file mode 100644 index 0000000..2b0b349 --- /dev/null +++ b/packages/meshbay-cli/src/meshbay_cli/__main__.py @@ -0,0 +1,134 @@ +""" +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 get, groups, login, logout, ls, put, trust +from .commands.context import Context +from .errors import INTERRUPTED, USAGE, CliError + +ACTIONS = {"login": login, "logout": logout, "groups": groups, "ls": ls, "get": get, + "put": put, "trust": trust} + + +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 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 + 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()) -- cgit v1.2.3