"""Explicit, one-shot terminal access to the farside client. The CLI never prints access URLs, purchase secrets or payment signatures. Reading a page and acknowledging processing are separate commands. """ from __future__ import annotations import argparse import json import sys from pathlib import Path from typing import Any from .errors import API_ERROR_CODES, FarsideError class _Parser(argparse.ArgumentParser): def error(self, message: str) -> None: # argparse normally repeats unrecognized arguments. A caller may have # mistakenly supplied a credential, so never reproduce argv here. self.exit(2, '{"error":"invalid_arguments","hint":"Use --help."}\n') def _parser() -> argparse.ArgumentParser: parser = _Parser(description="One-shot farside operations; no automatic payment or background polling.") parser.add_argument("--origin", default="https://farside.science") parser.add_argument("--state-dir", type=Path, default=Path.home() / ".farside-client") commands = parser.add_subparsers(dest="command", required=True, parser_class=_Parser) commands.add_parser("service", help="Read current service data from /docs.") commands.add_parser("terms", help="Read the current terms; review before prepare.") prepare = commands.add_parser("prepare", help="Save a complete opening request locally before sending it.") prepare.add_argument("--subject", required=True) _text_arguments(prepare) prepare.add_argument("--active-days", required=True, type=int, choices=(3, 7, 30, 180)) prepare.add_argument("--terms-version", required=True, type=int, help="Version whose terms you have read and accepted.") prepare.add_argument("--mode", choices=("research", "private"), default="research") prepare.add_argument("--coupon-file", type=Path, help="File containing the supplied coupon; kept with this request for recovery.") for command in ("open", "recover", "metadata", "check", "read", "ack", "send", "edit", "close"): sub = commands.add_parser(command) sub.add_argument("handle", help="Opaque local handle returned by prepare.") if command == "read": sub.add_argument("--limit", type=int, default=20) elif command in ("send", "edit"): _text_arguments(sub) if command == "edit": sub.add_argument("--seq", required=True, type=int) sub.add_argument("--revision", required=True, type=int) return parser def _text_arguments(parser: argparse.ArgumentParser) -> None: group = parser.add_mutually_exclusive_group(required=True) group.add_argument("--text-file", type=Path, help="UTF-8 message file; preferred over command-line text.") group.add_argument("--text", help="Message text; visible in your shell history and process arguments.") def _text(args: argparse.Namespace) -> str: if args.text_file is not None: return args.text_file.read_text(encoding="utf-8") return args.text def _only(value: dict[str, Any], fields: tuple[str, ...]) -> dict[str, Any]: return {key: value[key] for key in fields if key in value} def _execute(client: Any, args: argparse.Namespace) -> dict[str, Any]: if args.command == "service": return client.service() if args.command == "terms": return client.terms() if args.command == "prepare": coupon = None if args.coupon_file is None else args.coupon_file.read_text(encoding="utf-8").strip() handle = client.prepare(subject=args.subject, text=_text(args), active_days=args.active_days, terms_version=args.terms_version, mode=args.mode, coupon=coupon) return {"handle": handle, "status": "prepared"} handle = args.handle if args.command in ("open", "recover"): result = getattr(client, args.command)(handle) safe = _only(result, ("status", "expires_at", "opening_method", "purchase_status", "retry_after")) safe["handle"] = handle if result.get("status") == "payment_required": safe["hint"] = "Use the Python API with an authorized payment signer; this CLI does not sign payments." return safe if args.command == "metadata": return _only(client.metadata(handle), ("status", "started", "mode", "expires_at", "closed_at", "close_reason", "is_test", "limits")) if args.command == "check": return {"updates": client.check(handle)} if args.command == "read": page = client.read(handle, limit=args.limit) result = _only(page, ("after", "has_more", "status", "subject")) result["messages"] = [_only(message, ("seq", "revision", "author", "text", "created_at", "edited_at", "read")) for message in page["messages"]] return result if args.command == "ack": return _only(client.ack(handle), ("cursor",)) if args.command == "send": return _only(client.send(handle, _text(args)), ("seq", "revision")) if args.command == "edit": return _only(client.edit(handle, args.seq, args.revision, _text(args)), ("seq", "revision")) if args.command == "close": client.close(handle) return {"handle": handle, "closed": True} raise ValueError("Unknown operation") def _write(value: Any, stream: Any) -> None: stream.write(json.dumps(value, ensure_ascii=False, separators=(",", ":")) + "\n") def main(argv: list[str] | None = None) -> int: for stream in (sys.stdout, sys.stderr): reconfigure = getattr(stream, "reconfigure", None) if reconfigure is not None: reconfigure(encoding="utf-8") args = _parser().parse_args(argv) try: from . import FarsideClient client = FarsideClient(state_dir=args.state_dir, origin=args.origin) try: result = _execute(client, args) finally: close_transport = getattr(client, "shutdown", None) if close_transport is not None: close_transport() _write(result, sys.stdout) return 0 except KeyboardInterrupt: _write({"error": "interrupted"}, sys.stderr) return 130 except Exception as error: # Neither httpx exception messages nor file errors are safe to print: # they may include a secret dialogue path or a file containing a key. if isinstance(error, FarsideError): code = getattr(error, "code", None) or getattr(error, "error", None) if code not in API_ERROR_CODES: code = type(error).__name__ result = {"error": code} status = getattr(error, "status", None) if type(status) is int: result["status"] = status retry_after = getattr(error, "retry_after", None) if isinstance(retry_after, (int, float)): result["retry_after"] = retry_after elif isinstance(error, (OSError, UnicodeError)): result = {"error": "input_file_error"} else: result = {"error": "client_error"} _write(result, sys.stderr) return 1 if __name__ == "__main__": raise SystemExit(main())