aboutsummaryrefslogtreecommitdiffstats
path: root/packages/meshbay-cli/src/meshbay_cli/__main__.py
diff options
context:
space:
mode:
authorChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
committerChristophe Besson <cbesson@gmail.com>2026-10-14 22:20:15 +0200
commit422647e7898b8df67cd11f8fb060aec23dec84aa (patch)
tree7ecca0e8df1bc8a7d26cf4b471bd44906dd9cfd1 /packages/meshbay-cli/src/meshbay_cli/__main__.py
parent28c88908b88c58cba4de08710f6bb268ede02d57 (diff)
downloadmeshbay-422647e7898b8df67cd11f8fb060aec23dec84aa.tar.gz
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 <noreply@anthropic.com>
Diffstat (limited to 'packages/meshbay-cli/src/meshbay_cli/__main__.py')
-rw-r--r--packages/meshbay-cli/src/meshbay_cli/__main__.py134
1 files changed, 134 insertions, 0 deletions
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> [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="<action>")
+ 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())