RemoteRF 2.11.0__tar.gz → 2.12.3__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (126) hide show
  1. {remoterf-2.11.0/src/remoterf.egg-info → remoterf-2.12.3}/PKG-INFO +4 -2
  2. {remoterf-2.11.0 → remoterf-2.12.3}/pyproject.toml +7 -3
  3. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/admin.py +7 -5
  4. remoterf-2.12.3/src/remoterf/agent_cli.py +374 -0
  5. remoterf-2.12.3/src/remoterf/agent_docs.py +265 -0
  6. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/banner.py +3 -6
  7. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/config/config.py +4 -4
  8. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/app.py +206 -420
  9. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/availability.py +17 -68
  10. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/grpc_acc.py +30 -19
  11. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/grpc_client.py +41 -13
  12. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/messages.py +4 -4
  13. remoterf-2.12.3/src/remoterf/data/agent_guide.md +135 -0
  14. remoterf-2.12.3/src/remoterf/data/mcp_tools.json +126 -0
  15. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/backend.py +28 -0
  16. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/dynamic_device.py +11 -0
  17. remoterf-2.12.3/src/remoterf/mcp_server.py +324 -0
  18. remoterf-2.12.3/src/remoterf/ops.py +811 -0
  19. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/remoterf_cli.py +114 -33
  20. {remoterf-2.11.0 → remoterf-2.12.3/src/remoterf.egg-info}/PKG-INFO +4 -2
  21. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.egg-info/SOURCES.txt +10 -0
  22. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.egg-info/requires.txt +3 -0
  23. remoterf-2.12.3/tests/test_agent_cli.py +294 -0
  24. remoterf-2.12.3/tests/test_agent_docs.py +136 -0
  25. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_banner.py +4 -4
  26. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_bundled_drivers.py +2 -2
  27. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_cli_login_fresh.py +25 -2
  28. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_device_routes.py +8 -8
  29. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_devices_api.py +21 -0
  30. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_federated_devices.py +16 -0
  31. remoterf-2.12.3/tests/test_finish_prompt.py +36 -0
  32. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_homes.py +14 -8
  33. remoterf-2.12.3/tests/test_mcp_server.py +223 -0
  34. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_messages.py +11 -10
  35. remoterf-2.12.3/tests/test_ops.py +443 -0
  36. remoterf-2.11.0/tests/test_finish_prompt.py +0 -20
  37. {remoterf-2.11.0 → remoterf-2.12.3}/LICENSE +0 -0
  38. {remoterf-2.11.0 → remoterf-2.12.3}/README.md +0 -0
  39. {remoterf-2.11.0 → remoterf-2.12.3}/setup.cfg +0 -0
  40. {remoterf-2.11.0 → remoterf-2.12.3}/setup.py +0 -0
  41. {remoterf-2.11.0 → remoterf-2.12.3}/src/_remoterf_alias.py +0 -0
  42. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/__init__.py +0 -0
  43. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/__init__.py +0 -0
  44. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/__init__.py +0 -0
  45. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  46. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  47. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  48. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/grpc.proto +0 -0
  49. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  50. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  51. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/grpc/v2_codec.py +0 -0
  52. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/tos_notice.txt +0 -0
  53. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/__init__.py +0 -0
  54. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/ansi_codes.py +0 -0
  55. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/api_token.py +0 -0
  56. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/list_string.py +0 -0
  57. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/common/utils/process_arg.py +0 -0
  58. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/config/__init__.py +0 -0
  59. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/config/cert_fetcher.py +0 -0
  60. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/__init__.py +0 -0
  61. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/acc_login.py +0 -0
  62. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/banner_note.txt +0 -0
  63. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/direct_path.py +0 -0
  64. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  65. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/secure_channel.py +0 -0
  66. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/core/v2_errors.py +0 -0
  67. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/__init__.py +0 -0
  68. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/direct.py +0 -0
  69. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/homes.py +0 -0
  70. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/http.py +0 -0
  71. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/lanpass.py +0 -0
  72. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/deployment/state.py +0 -0
  73. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/__init__.py +0 -0
  74. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/_virtual.py +0 -0
  75. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  76. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  77. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/dynamic_v2.py +0 -0
  78. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  79. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  80. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  81. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  82. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/schema_review.py +0 -0
  83. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  84. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  85. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/support/__init__.py +0 -0
  86. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  87. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/support/uhd.py +0 -0
  88. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  89. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  90. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  91. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  92. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/usrp/__init__.py +0 -0
  93. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  94. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  95. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf/version.py +0 -0
  96. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.egg-info/dependency_links.txt +0 -0
  97. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.egg-info/entry_points.txt +0 -0
  98. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.egg-info/top_level.txt +0 -0
  99. {remoterf-2.11.0 → remoterf-2.12.3}/src/remoterf.py +0 -0
  100. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_batch.py +0 -0
  101. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_config_address.py +0 -0
  102. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_deployment_accounts.py +0 -0
  103. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_direct_path_fixed.py +0 -0
  104. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_direct_path_global.py +0 -0
  105. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_direct_path_partner.py +0 -0
  106. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_direct_path_race.py +0 -0
  107. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_direct_path_relay.py +0 -0
  108. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_dynamic_device_codegen.py +0 -0
  109. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_dynamic_v2.py +0 -0
  110. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_fetch_idl_fallback.py +0 -0
  111. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_gate_i_direct_path.py +0 -0
  112. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_grpc_client_target.py +0 -0
  113. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_lan_fallback.py +0 -0
  114. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_native_account_credentials.py +0 -0
  115. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_native_devices.py +0 -0
  116. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_old_import_name.py +0 -0
  117. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_partner_dynamic_v2.py +0 -0
  118. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_pluto_virtual.py +0 -0
  119. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_process_arg.py +0 -0
  120. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_relay_paging.py +0 -0
  121. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_remote_admin.py +0 -0
  122. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_remoterf_cli.py +0 -0
  123. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_rx_readahead.py +0 -0
  124. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_schema_review.py +0 -0
  125. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_ti_mmwave.py +0 -0
  126. {remoterf-2.11.0 → remoterf-2.12.3}/tests/test_upgrade_rescue.py +0 -0
@@ -1,8 +1,8 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.11.0
3
+ Version: 2.12.3
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
- Author-email: Ethan Ge <ethoGalaxy@gmail.com>
5
+ Author-email: Ethan Ge <84103791+ethange1@users.noreply.github.com>
6
6
  License-Expression: GPL-3.0-or-later
7
7
  Classifier: Programming Language :: Python :: 3
8
8
  Classifier: Operating System :: OS Independent
@@ -21,6 +21,8 @@ Requires-Dist: pydantic<3.0,>=2.6
21
21
  Requires-Dist: keyring<26,>=24
22
22
  Requires-Dist: aioice<0.11,>=0.10
23
23
  Requires-Dist: aioquic<2.0,>=1.2
24
+ Provides-Extra: mcp
25
+ Requires-Dist: mcp<3,>=2.3; extra == "mcp"
24
26
  Dynamic: license-file
25
27
 
26
28
  # Remote RF
@@ -4,12 +4,12 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.11.0"
7
+ version = "2.12.3"
8
8
  description = "A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts."
9
9
  readme = "README.md"
10
10
  license = "GPL-3.0-or-later"
11
11
  license-files = ["LICENSE"]
12
- authors = [{ name = "Ethan Ge", email = "ethoGalaxy@gmail.com" }]
12
+ authors = [{ name = "Ethan Ge", email = "84103791+ethange1@users.noreply.github.com" }]
13
13
  requires-python = ">=3.10"
14
14
  dependencies = [
15
15
  "remoterf-federation-core==0.16.0",
@@ -30,6 +30,10 @@ classifiers = [
30
30
  "Operating System :: OS Independent",
31
31
  ]
32
32
 
33
+ [project.optional-dependencies]
34
+ # `remoterf mcp`: the local MCP server for AI assistants.
35
+ mcp = ["mcp>=2.3,<3"]
36
+
33
37
  [project.scripts]
34
38
  remoterf = "remoterf.remoterf_cli:main"
35
39
 
@@ -43,4 +47,4 @@ py-modules = ["remoterf", "_remoterf_alias"]
43
47
  where = ["src"]
44
48
 
45
49
  [tool.setuptools.package-data]
46
- "*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto"]
50
+ "*" = ["*.crt", "*.key", "*.pem", "*.txt", "*.pyi", "*.proto", "*.md", "*.json"]
@@ -340,9 +340,11 @@ def cmd_login(target: str | None) -> int:
340
340
  alias = entry.get("alias") or _resolve(lab)[3]
341
341
  strict = trusted(alias)
342
342
  if not strict:
343
- print(f"This machine has not trusted {lab}'s admin host key. Safer: a code from the lab console, "
344
- f"`remoterf --remote-admin trust {lab} SHA256:…` with the fingerprint from a trusted place, or a "
345
- f"copy of {known_hosts_path()} from a paired machine. Accepting it below on first sight trusts the "
343
+ print(f"This machine has not trusted {lab}'s admin host key. Safer options:\n"
344
+ "• Use a code from the lab console.\n"
345
+ f"• Or run `remoterf --remote-admin trust {lab} SHA256:…` with a verified fingerprint.\n"
346
+ f"• Copy {known_hosts_path()} from a paired machine.\n"
347
+ "Accepting it below on first sight trusts the "
346
348
  "network you are on.", file=sys.stderr)
347
349
  _save_lab(lab, {**entry, "user": user, "alias": alias})
348
350
  return _exec(ssh_argv(lab, user, alias, strict=strict))
@@ -371,9 +373,9 @@ def cmd_hostkey(lab: str) -> int:
371
373
 
372
374
 
373
375
  USAGE = """Usage:
374
- remoterf --remote-admin [<user>@]<lab> the lab's console (a menu with no lab)
376
+ remoterf --remote-admin [<user>@]<lab> open a lab's admin console (prompts if <lab> is omitted)
375
377
  remoterf --remote-admin pair <user>@<lab> <code> first time on this machine (code from: serverrf admins add/code)
376
- remoterf --remote-admin trust <lab> SHA256:… trust a host key fingerprint read somewhere trusted
378
+ remoterf --remote-admin trust <lab> SHA256:… trust a verified host key fingerprint
377
379
  remoterf --remote-admin hostkey <lab> show the lab's current host key"""
378
380
 
379
381
 
@@ -0,0 +1,374 @@
1
+ # Copyright (C) 2026 RemoteRF
2
+ #
3
+ # This program is free software: you can redistribute it and/or modify
4
+ # it under the terms of the GNU General Public License as published by
5
+ # the Free Software Foundation, either version 3 of the License, or
6
+ # (at your option) any later version.
7
+ #
8
+ # This program is distributed in the hope that it will be useful,
9
+ # but WITHOUT ANY WARRANTY; without even the implied warranty of
10
+ # MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
11
+ # GNU General Public License for more details.
12
+ #
13
+ # You should have received a copy of the GNU General Public License
14
+ # along with this program. If not, see <https://www.gnu.org/licenses/>.
15
+
16
+ """The scriptable CLI over remoterf.ops: no prompts, no colour, no spinner.
17
+ With --json, stdout carries exactly one JSON object; messages go to stderr."""
18
+
19
+ import argparse
20
+ import json
21
+ import sys
22
+ from contextlib import redirect_stdout
23
+ from datetime import datetime
24
+
25
+ from . import ops
26
+ from .core.messages import explain
27
+ from .deployment.http import AccountBackendError
28
+ from .ops import RemoteRFOpsError
29
+
30
+ COMMANDS = ("auth", "devices", "slots", "reservations", "reserve", "cancel", "doctor")
31
+
32
+
33
+ class _Parser(argparse.ArgumentParser):
34
+ def error(self, message):
35
+ raise RemoteRFOpsError("usage", f"{self.prog}: {message}")
36
+
37
+
38
+ def _clean(value):
39
+ """Server and deployment text, safe for a terminal."""
40
+ return "".join(c for c in str(value) if c.isprintable())
41
+
42
+
43
+ # ── auth ─────────────────────────────────────────────────────────────
44
+
45
+ def _default_home():
46
+ """The home `remoterf -l` would pick on its own."""
47
+ from .deployment import homes
48
+
49
+ last = homes.last_target()
50
+ if last:
51
+ return homes.GLOBAL_ACCOUNTS if last["kind"] == "global" else last["name"]
52
+ saved = sorted(homes.homes())
53
+ if len(saved) > 1 or saved and homes.has_global_account():
54
+ raise RemoteRFOpsError("usage", "Several logins are saved here; choose one with --home.")
55
+ return saved[0] if saved else homes.GLOBAL_ACCOUNTS
56
+
57
+
58
+ def _login_global(user, password):
59
+ """`remoterf -l global` then login: Global selected, its session stored."""
60
+ from .deployment import homes
61
+ from .deployment.backend import HttpsJsonAccountBackend
62
+ from .deployment.state import origin, select_target
63
+ from .remoterf_cli import _global_origin
64
+
65
+ selected = origin(_global_origin())
66
+ backend = HttpsJsonAccountBackend(selected)
67
+ try:
68
+ deployment_id = backend.capabilities["deployment_id"]
69
+ select_target({"origin": selected, "transport": "https-json", "deployment_id": deployment_id})
70
+ homes.remember_global_account(selected, deployment_id)
71
+ homes.set_last_target("global")
72
+ backend.login(user, password)
73
+ try:
74
+ homes.sync_linked(backend.me()) # the labs `remoterf auth login --home <lab>` then needs
75
+ except Exception: # noqa: BLE001 - refreshed again at the next login
76
+ pass
77
+ finally:
78
+ backend.close()
79
+
80
+
81
+ def _login_lab(home, user, password):
82
+ """`remoterf -l NAME` then login: the deployment selected, its session token stored."""
83
+ from .core.grpc_acc import RemoteRFAccount
84
+ from .deployment import homes
85
+ from .deployment.backend import LegacyGrpcAccountBackend
86
+
87
+ try:
88
+ if home in homes.homes():
89
+ homes.connect(home)
90
+ else:
91
+ home = homes.register_home(f"{home}.{homes.GLOBAL_DOMAIN}")[0]
92
+ backend = LegacyGrpcAccountBackend()
93
+ except (RuntimeError, ValueError) as exc:
94
+ raise RemoteRFOpsError("deployment_unreachable", f"Could not connect to {home}: {explain(exc, where=home)}") from exc
95
+ if "@" in user and homes.is_linked_home(home):
96
+ # Here the login is the username; an email would send the Global password as typed.
97
+ raise RemoteRFOpsError("usage", "Log in here with your username, not your email.")
98
+ # A lab this Global account has a login at gets a secret bound to it, not the password.
99
+ account = RemoteRFAccount(user, homes.lab_password(home, user, password), backend=backend, quiet=True)
100
+ if not account.login_user(remember=True):
101
+ raise RemoteRFOpsError("invalid_credentials")
102
+ if not account.session_token:
103
+ raise RemoteRFOpsError("deployment_error",
104
+ f"{home} issues no session token, so this login can't be saved; use `remoterf -l {home}`.")
105
+ homes.remember_login(home, account.username, account.session_token)
106
+
107
+
108
+ @ops._op
109
+ def _login(a):
110
+ from .config.config import _read_tos_notice, remember_tos_agreement, tos_agreed
111
+ from .deployment import homes
112
+
113
+ if a.accept_tos:
114
+ print(_read_tos_notice(), file=sys.stderr)
115
+ remember_tos_agreement()
116
+ elif not tos_agreed():
117
+ raise RemoteRFOpsError("usage", "Logging in needs your agreement to the RemoteRF Terms of Service: add --accept-tos.")
118
+ password = sys.stdin.readline().rstrip("\r\n")
119
+ if not password:
120
+ raise RemoteRFOpsError("usage", "No password on stdin.")
121
+ homes.migrate_legacy_target()
122
+ name, lab = homes.split_account(a.user)
123
+ home = (a.home or lab or _default_home()).lower()
124
+ user = name if lab == home else a.user # `jdoe@ucla` at ucla is `jdoe` there
125
+ if home == homes.GLOBAL_ACCOUNTS:
126
+ _login_global(user, password)
127
+ else:
128
+ _login_lab(home, user, password)
129
+ return ops.whoami(ops.saved_session())
130
+
131
+
132
+ @ops._op
133
+ def _logout(a):
134
+ """Revoke the saved session (best effort), then forget it locally."""
135
+ from .deployment.state import CredentialStore, load_target
136
+
137
+ target = load_target()
138
+ if target and target["transport"] == "https-json":
139
+ from .deployment.backend import selected_backend
140
+
141
+ try:
142
+ backend = selected_backend()
143
+ try:
144
+ if backend.resume():
145
+ backend.logout()
146
+ finally:
147
+ backend.close()
148
+ except Exception: # noqa: BLE001 - revocation is best effort
149
+ pass
150
+ CredentialStore(target["origin"], target["deployment_id"]).clear()
151
+ return {"logged_out": True}
152
+ from .core.grpc_acc import RemoteRFAccount
153
+ from .core.grpc_client import _active_home_name
154
+ from .deployment import homes
155
+
156
+ home = _active_home_name()
157
+ saved = homes.recall_login(home) if home else None
158
+ if saved:
159
+ account = RemoteRFAccount(saved["username"], saved["secret"], quiet=True)
160
+ account.session_token = saved["secret"]
161
+ account.logout()
162
+ homes.forget_login(home)
163
+ return {"logged_out": True}
164
+
165
+
166
+ @ops._op
167
+ def _apps(a):
168
+ s = ops.saved_session()
169
+ if s.home_kind != "global":
170
+ raise RemoteRFOpsError("invalid_argument", "Connected apps belong to a Global account (remoterf auth login --home global).")
171
+ backend = s.account.backend
172
+ try:
173
+ return backend.revoke_app(a.revoke) if a.revoke else backend.connected_apps()
174
+ except AccountBackendError as exc:
175
+ if exc.code != "not_found":
176
+ raise
177
+ raise RemoteRFOpsError("not_found", "Connected apps arrive with the RemoteRF Global connector, "
178
+ "which isn't available yet.") from None
179
+
180
+
181
+ # ── devices and reservations ─────────────────────────────────────────
182
+
183
+ def _reserve(a):
184
+ if not a.confirm:
185
+ raise RemoteRFOpsError("usage", "Dry run only. Re-run with --confirm to reserve this slot.")
186
+ return ops.reserve(ops.saved_session(), a.handle, a.start, a.end, reveal_token=a.reveal_token)
187
+
188
+
189
+ def _cancel(a):
190
+ if not a.confirm:
191
+ raise RemoteRFOpsError("usage", "Dry run only. Re-run with --confirm to cancel this reservation.")
192
+ return ops.cancel(ops.saved_session(), a.reservation_id)
193
+
194
+
195
+ # ── doctor ───────────────────────────────────────────────────────────
196
+
197
+ def _doctor(a):
198
+ """What this install sees; never a secret, never a failure."""
199
+ import importlib.util
200
+ import platform
201
+
202
+ from .deployment import homes
203
+ from .deployment.state import CredentialStore, load_target, root
204
+
205
+ out = {"version": ops._version(), "python": platform.python_version(), "executable": sys.executable,
206
+ "config_dir": str(root().parent), "home": None, "home_kind": None, "endpoint": None,
207
+ "logged_in": False, "reachable": False, "mcp_installed": importlib.util.find_spec("mcp") is not None,
208
+ "problems": []}
209
+ try:
210
+ target = load_target()
211
+ if target and target["transport"] == "https-json":
212
+ store = CredentialStore(target["origin"], target["deployment_id"])
213
+ out.update(home=homes.GLOBAL_ACCOUNTS, home_kind="global", endpoint=target["origin"],
214
+ logged_in=(store.directory / "selected.json").exists())
215
+ else:
216
+ from .deployment.direct import resolve_active_profile
217
+
218
+ profile = resolve_active_profile()
219
+ if profile:
220
+ out.update(home=profile.home, home_kind="deployment", endpoint=profile.grpc_endpoint,
221
+ logged_in=bool(profile.home and homes.recall_login(profile.home)))
222
+ if out["endpoint"]:
223
+ from .remoterf_cli import _connected_server
224
+
225
+ out["reachable"] = _connected_server(3.0) is not None
226
+ except Exception as exc: # noqa: BLE001 - doctor reports, it never fails
227
+ out["problems"].append(explain(exc))
228
+ if not out["endpoint"]:
229
+ out["problems"].append("No home selected: run `remoterf auth login`.")
230
+ else:
231
+ if not out["logged_in"]:
232
+ out["problems"].append("No saved login: run `remoterf auth login`.")
233
+ if not out["reachable"]:
234
+ out["problems"].append(f"{out['endpoint']} did not answer.")
235
+ if not out["mcp_installed"]:
236
+ out["problems"].append('For `remoterf mcp`: pip install "remoterf[mcp]"')
237
+ return out
238
+
239
+
240
+ # ── human output ─────────────────────────────────────────────────────
241
+
242
+ def _local(ts):
243
+ return datetime.fromtimestamp(ts).strftime("%a %Y-%m-%d %H:%M") if ts else "-"
244
+
245
+
246
+ def _table(out, rows):
247
+ rows = [[_clean(c) for c in row] for row in rows]
248
+ widths = [max(len(r[i]) for r in rows) for i in range(len(rows[0]))] if rows else []
249
+ for row in rows:
250
+ print(" ".join(c.ljust(w) for c, w in zip(row, widths)).rstrip(), file=out)
251
+
252
+
253
+ def _show_whoami(r, out):
254
+ print(_clean(f"{r['account']} ({r['home_kind']}); deployments: {', '.join(r['deployments']) or '-'}"), file=out)
255
+
256
+
257
+ def _show_devices(r, out):
258
+ _table(out, [[d["handle"] or d["name"], d["state"] if d["online"] else "offline", d["type"] or "-", d["deployment"] or "-",
259
+ f"until {_local(d['until_ts'])}" if d["until_ts"] else ""] for d in r["devices"]])
260
+
261
+
262
+ def _show_slots(r, out):
263
+ print(_clean(f"{r['handle']}: {r['slot_minutes']}-minute slots ({r['timezone']})"), file=sys.stderr)
264
+ _table(out, [[_local(s["start_ts"]), "-", _local(s["end_ts"])] for s in r["slots"]])
265
+
266
+
267
+ def _ref(x):
268
+ """A Global reservation id '<deployment id>:10' as '10@ucla', which `remoterf cancel` takes."""
269
+ _, colon, num = str(x["reservation_id"]).rpartition(":")
270
+ return f"{num}@{x['handle'].rpartition('@')[2]}" if colon and x.get("handle") else x["reservation_id"]
271
+
272
+
273
+ def _show_reservations(r, out):
274
+ if not r["reservations"]:
275
+ print("No reservations.", file=out)
276
+ return
277
+ _table(out, [[_ref(x), x["handle"] or x["device"], _local(x["start_ts"]), "-", _local(x["end_ts"])]
278
+ for x in r["reservations"]])
279
+
280
+
281
+ def _show_reserved(r, out):
282
+ print(_clean(f"Reserved {r['handle']} {_local(r['start_ts'])} - {_local(r['end_ts'])} "
283
+ f"(reservation {_ref(r) or 'unknown'})."), file=out)
284
+ print(_clean(f'Use token="{r["use_as_token"]}"' + (f', or the raw token {r["token"]}' if r.get("token") else "")), file=out)
285
+
286
+
287
+ def _show_apps(r, out):
288
+ if "apps" not in r:
289
+ print("Revoked.", file=out)
290
+ return
291
+ _table(out, [[x.get("client_id"), x.get("client_name"), " ".join(x.get("scopes") or ()), f"last used {x.get('last_used_at') or 'never'}"]
292
+ for x in r["apps"]] or [["No connected apps."]])
293
+
294
+
295
+ def _show_doctor(r, out):
296
+ for key, value in r.items():
297
+ if key != "problems":
298
+ print(_clean(f"{key}: {value}"), file=out)
299
+ for problem in r["problems"]:
300
+ print(_clean(f"! {problem}"), file=out)
301
+
302
+
303
+ # ── main ─────────────────────────────────────────────────────────────
304
+
305
+ def _parser():
306
+ common = argparse.ArgumentParser(add_help=False)
307
+ common.add_argument("--json", action="store_true", help="print one JSON object on stdout")
308
+ parser = _Parser(prog="remoterf", description="Scriptable RemoteRF commands (no prompts).")
309
+ sub = parser.add_subparsers(dest="command", required=True)
310
+
311
+ def command(where, name, run, show, help):
312
+ p = where.add_parser(name, parents=[common], help=help, description=help)
313
+ p.set_defaults(run=run, show=show)
314
+ return p
315
+
316
+ auth = sub.add_parser("auth", help="the saved login").add_subparsers(dest="action", required=True)
317
+ p = command(auth, "login", _login, _show_whoami, "log in without prompts and keep the login saved")
318
+ p.add_argument("--home", help="global, or a deployment name (default: the account's deployment, else the last home used)")
319
+ p.add_argument("--user", required=True, help="NAME, or NAME@deployment")
320
+ p.add_argument("--password-stdin", action="store_true", required=True, help="read the password from stdin")
321
+ p.add_argument("--accept-tos", action="store_true", help="agree to the RemoteRF Terms of Service")
322
+ command(auth, "status", lambda a: ops.whoami(ops.saved_session()), _show_whoami, "show active login and deployments")
323
+ command(sub, "whoami", lambda a: ops.whoami(ops.saved_session()), _show_whoami, "same as `auth status`")
324
+ command(auth, "logout", _logout, lambda r, out: print("Logged out.", file=out), "end and forget the saved login")
325
+ p = command(auth, "apps", _apps, _show_apps, "apps connected through the RemoteRF Global connector (not available yet)")
326
+ p.add_argument("--revoke", metavar="CLIENT_ID", help="disconnect one app")
327
+ p = command(sub, "devices", lambda a: ops.list_devices(ops.saved_session(), deployment=a.deployment, online_only=not a.all),
328
+ _show_devices, "list devices and current availability")
329
+ p.add_argument("--deployment", help="only this deployment's devices")
330
+ p.add_argument("--all", action="store_true", help="include offline devices")
331
+ p = command(sub, "slots", lambda a: ops.find_slots(ops.saved_session(), a.handle, days=a.days),
332
+ _show_slots, "free slots on a device, aligned like resdev")
333
+ p.add_argument("handle", help="Name@deployment, for example Pluto_00@ucla")
334
+ p.add_argument("--days", type=int, default=1)
335
+ command(sub, "reservations", lambda a: ops.list_reservations(ops.saved_session()), _show_reservations, "your reservations")
336
+ p = command(sub, "reserve", _reserve, _show_reserved, "reserve a device (needs --confirm)")
337
+ p.add_argument("handle")
338
+ p.add_argument("--start", required=True, help="RFC 3339, for example 2026-10-15T14:00:00Z")
339
+ p.add_argument("--end", required=True)
340
+ p.add_argument("--confirm", action="store_true", help="confirm reservation")
341
+ p.add_argument("--reveal-token", action="store_true", help="include the raw reservation token")
342
+ p = command(sub, "cancel", _cancel, lambda r, out: print("Canceled.", file=out),
343
+ "cancel a reservation (needs --confirm)")
344
+ p.add_argument("reservation_id", help="as `remoterf reservations` shows it, or a JSON reservation_id")
345
+ p.add_argument("--confirm", action="store_true", help="confirm cancellation")
346
+ command(sub, "doctor", _doctor, _show_doctor, "check this install, its home and its login")
347
+ return parser
348
+
349
+
350
+ def main(argv) -> int:
351
+ out = sys.stdout
352
+ as_json = "--json" in argv
353
+ try:
354
+ args = _parser().parse_args(argv)
355
+ as_json = args.json
356
+ with redirect_stdout(sys.stderr): # a stray library print must not reach stdout
357
+ result = args.run(args)
358
+ except RemoteRFOpsError as exc:
359
+ if as_json:
360
+ print(json.dumps(exc.to_dict()), file=out)
361
+ print(_clean(f"remoterf: {exc.message}"), file=sys.stderr)
362
+ return exc.exit_code
363
+ except KeyboardInterrupt:
364
+ print(file=sys.stderr)
365
+ return 130
366
+ except SystemExit as exc: # --help
367
+ return exc.code if isinstance(exc.code, int) else 0
368
+ if as_json:
369
+ print(json.dumps(result), file=out)
370
+ else:
371
+ for note in result.get("notes", ()):
372
+ print(_clean(note), file=sys.stderr)
373
+ args.show(result, out)
374
+ return 0