RemoteRF 2.11.0__tar.gz → 2.12.2__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.2}/PKG-INFO +3 -1
  2. {remoterf-2.11.0 → remoterf-2.12.2}/pyproject.toml +6 -2
  3. remoterf-2.12.2/src/remoterf/agent_cli.py +370 -0
  4. remoterf-2.12.2/src/remoterf/agent_docs.py +265 -0
  5. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/app.py +34 -314
  6. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/availability.py +17 -68
  7. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/grpc_acc.py +27 -16
  8. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/grpc_client.py +41 -13
  9. remoterf-2.12.2/src/remoterf/data/agent_guide.md +135 -0
  10. remoterf-2.12.2/src/remoterf/data/mcp_tools.json +126 -0
  11. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/backend.py +28 -0
  12. remoterf-2.12.2/src/remoterf/mcp_server.py +324 -0
  13. remoterf-2.12.2/src/remoterf/ops.py +811 -0
  14. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/remoterf_cli.py +100 -14
  15. {remoterf-2.11.0 → remoterf-2.12.2/src/remoterf.egg-info}/PKG-INFO +3 -1
  16. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/SOURCES.txt +10 -0
  17. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/requires.txt +3 -0
  18. remoterf-2.12.2/tests/test_agent_cli.py +294 -0
  19. remoterf-2.12.2/tests/test_agent_docs.py +136 -0
  20. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_cli_login_fresh.py +25 -2
  21. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_devices_api.py +21 -0
  22. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_federated_devices.py +16 -0
  23. remoterf-2.12.2/tests/test_finish_prompt.py +36 -0
  24. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_homes.py +14 -8
  25. remoterf-2.12.2/tests/test_mcp_server.py +223 -0
  26. remoterf-2.12.2/tests/test_ops.py +443 -0
  27. remoterf-2.11.0/tests/test_finish_prompt.py +0 -20
  28. {remoterf-2.11.0 → remoterf-2.12.2}/LICENSE +0 -0
  29. {remoterf-2.11.0 → remoterf-2.12.2}/README.md +0 -0
  30. {remoterf-2.11.0 → remoterf-2.12.2}/setup.cfg +0 -0
  31. {remoterf-2.11.0 → remoterf-2.12.2}/setup.py +0 -0
  32. {remoterf-2.11.0 → remoterf-2.12.2}/src/_remoterf_alias.py +0 -0
  33. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/__init__.py +0 -0
  34. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/admin.py +0 -0
  35. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/__init__.py +0 -0
  36. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/__init__.py +0 -0
  37. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities.proto +0 -0
  38. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2.py +0 -0
  39. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/deployment_capabilities_pb2_grpc.py +0 -0
  40. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc.proto +0 -0
  41. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2.py +0 -0
  42. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/grpc_pb2_grpc.py +0 -0
  43. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/grpc/v2_codec.py +0 -0
  44. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/tos_notice.txt +0 -0
  45. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/__init__.py +0 -0
  46. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/ansi_codes.py +0 -0
  47. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/api_token.py +0 -0
  48. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/banner.py +0 -0
  49. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/list_string.py +0 -0
  50. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/common/utils/process_arg.py +0 -0
  51. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/__init__.py +0 -0
  52. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/cert_fetcher.py +0 -0
  53. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/config/config.py +0 -0
  54. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/__init__.py +0 -0
  55. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/acc_login.py +0 -0
  56. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/banner_note.txt +0 -0
  57. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/direct_path.py +0 -0
  58. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/dynamic_v2_transport.py +0 -0
  59. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/messages.py +0 -0
  60. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/secure_channel.py +0 -0
  61. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/core/v2_errors.py +0 -0
  62. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/__init__.py +0 -0
  63. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/direct.py +0 -0
  64. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/homes.py +0 -0
  65. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/http.py +0 -0
  66. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/lanpass.py +0 -0
  67. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/deployment/state.py +0 -0
  68. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/__init__.py +0 -0
  69. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/_virtual.py +0 -0
  70. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/__init__.py +0 -0
  71. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/adalm_pluto/adalm_pluto_remote.py +0 -0
  72. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_device.py +0 -0
  73. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/dynamic_v2.py +0 -0
  74. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/__init__.py +0 -0
  75. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/hackrf/hackrf_remote.py +0 -0
  76. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/__init__.py +0 -0
  77. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/rtl_sdr/rtl_sdr_remote.py +0 -0
  78. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/schema_review.py +0 -0
  79. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/__init__.py +0 -0
  80. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/sim_sdr/sim_sdr_remote.py +0 -0
  81. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/__init__.py +0 -0
  82. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/ti_mmwave.py +0 -0
  83. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd.py +0 -0
  84. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_dsp.py +0 -0
  85. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/support/uhd_v2.py +0 -0
  86. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/__init__.py +0 -0
  87. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/ti_mmwave/ti_mmwave_remote.py +0 -0
  88. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/__init__.py +0 -0
  89. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.py +0 -0
  90. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/drivers/usrp/usrp_remote.pyi +0 -0
  91. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf/version.py +0 -0
  92. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/dependency_links.txt +0 -0
  93. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/entry_points.txt +0 -0
  94. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.egg-info/top_level.txt +0 -0
  95. {remoterf-2.11.0 → remoterf-2.12.2}/src/remoterf.py +0 -0
  96. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_banner.py +0 -0
  97. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_batch.py +0 -0
  98. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_bundled_drivers.py +0 -0
  99. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_config_address.py +0 -0
  100. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_deployment_accounts.py +0 -0
  101. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_device_routes.py +0 -0
  102. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_fixed.py +0 -0
  103. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_global.py +0 -0
  104. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_partner.py +0 -0
  105. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_race.py +0 -0
  106. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_direct_path_relay.py +0 -0
  107. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_dynamic_device_codegen.py +0 -0
  108. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_dynamic_v2.py +0 -0
  109. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_fetch_idl_fallback.py +0 -0
  110. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_gate_i_direct_path.py +0 -0
  111. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_grpc_client_target.py +0 -0
  112. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_lan_fallback.py +0 -0
  113. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_messages.py +0 -0
  114. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_native_account_credentials.py +0 -0
  115. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_native_devices.py +0 -0
  116. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_old_import_name.py +0 -0
  117. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_partner_dynamic_v2.py +0 -0
  118. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_pluto_virtual.py +0 -0
  119. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_process_arg.py +0 -0
  120. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_relay_paging.py +0 -0
  121. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_remote_admin.py +0 -0
  122. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_remoterf_cli.py +0 -0
  123. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_rx_readahead.py +0 -0
  124. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_schema_review.py +0 -0
  125. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_ti_mmwave.py +0 -0
  126. {remoterf-2.11.0 → remoterf-2.12.2}/tests/test_upgrade_rescue.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: remoterf
3
- Version: 2.11.0
3
+ Version: 2.12.2
4
4
  Summary: A python API to remotely access signal centric hardware. Client-side only! Courtesy of Wireless Lab @ UCLA & Prof. Ian Roberts.
5
5
  Author-email: Ethan Ge <ethoGalaxy@gmail.com>
6
6
  License-Expression: GPL-3.0-or-later
@@ -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,7 +4,7 @@ build-backend = "setuptools.build_meta"
4
4
 
5
5
  [project]
6
6
  name = "remoterf"
7
- version = "2.11.0"
7
+ version = "2.12.2"
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"
@@ -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"]
@@ -0,0 +1,370 @@
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", "Reserving changes the deployment's schedule: re-run with --confirm.")
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", "Cancelling gives the slot up: re-run with --confirm.")
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
+ _table(out, [[_ref(x), x["handle"] or x["device"], _local(x["start_ts"]), "-", _local(x["end_ts"])]
275
+ for x in r["reservations"]])
276
+
277
+
278
+ def _show_reserved(r, out):
279
+ print(_clean(f"Reserved {r['handle']} {_local(r['start_ts'])} - {_local(r['end_ts'])} "
280
+ f"(reservation {_ref(r) or 'unknown'})."), file=out)
281
+ print(_clean(f'Use token="{r["use_as_token"]}"' + (f', or the raw token {r["token"]}' if r.get("token") else "")), file=out)
282
+
283
+
284
+ def _show_apps(r, out):
285
+ if "apps" not in r:
286
+ print("Revoked.", file=out)
287
+ return
288
+ _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'}"]
289
+ for x in r["apps"]] or [["No connected apps."]])
290
+
291
+
292
+ def _show_doctor(r, out):
293
+ for key, value in r.items():
294
+ if key != "problems":
295
+ print(_clean(f"{key}: {value}"), file=out)
296
+ for problem in r["problems"]:
297
+ print(_clean(f"! {problem}"), file=out)
298
+
299
+
300
+ # ── main ─────────────────────────────────────────────────────────────
301
+
302
+ def _parser():
303
+ common = argparse.ArgumentParser(add_help=False)
304
+ common.add_argument("--json", action="store_true", help="print one JSON object on stdout")
305
+ parser = _Parser(prog="remoterf", description="Scriptable RemoteRF commands (no prompts).")
306
+ sub = parser.add_subparsers(dest="command", required=True)
307
+
308
+ def command(where, name, run, show, help):
309
+ p = where.add_parser(name, parents=[common], help=help, description=help)
310
+ p.set_defaults(run=run, show=show)
311
+ return p
312
+
313
+ auth = sub.add_parser("auth", help="the saved login").add_subparsers(dest="action", required=True)
314
+ p = command(auth, "login", _login, _show_whoami, "log in without prompts and keep the login saved")
315
+ p.add_argument("--home", help="global, or a deployment name (default: the account's deployment, else the last home used)")
316
+ p.add_argument("--user", required=True, help="NAME, or NAME@deployment")
317
+ p.add_argument("--password-stdin", action="store_true", required=True, help="read the password from stdin")
318
+ p.add_argument("--accept-tos", action="store_true", help="agree to the RemoteRF Terms of Service")
319
+ command(auth, "status", lambda a: ops.whoami(ops.saved_session()), _show_whoami, "who the saved login is")
320
+ command(auth, "logout", _logout, lambda r, out: print("Logged out.", file=out), "end and forget the saved login")
321
+ p = command(auth, "apps", _apps, _show_apps, "apps connected through the RemoteRF Global connector (not available yet)")
322
+ p.add_argument("--revoke", metavar="CLIENT_ID", help="disconnect one app")
323
+ p = command(sub, "devices", lambda a: ops.list_devices(ops.saved_session(), deployment=a.deployment, online_only=not a.all),
324
+ _show_devices, "devices and their state now")
325
+ p.add_argument("--deployment", help="only this deployment's devices")
326
+ p.add_argument("--all", action="store_true", help="include offline devices")
327
+ p = command(sub, "slots", lambda a: ops.find_slots(ops.saved_session(), a.handle, days=a.days),
328
+ _show_slots, "free slots on a device, aligned like resdev")
329
+ p.add_argument("handle", help="Name@deployment, for example Pluto_00@ucla")
330
+ p.add_argument("--days", type=int, default=1)
331
+ command(sub, "reservations", lambda a: ops.list_reservations(ops.saved_session()), _show_reservations, "your reservations")
332
+ p = command(sub, "reserve", _reserve, _show_reserved, "reserve a device (needs --confirm)")
333
+ p.add_argument("handle")
334
+ p.add_argument("--start", required=True, help="RFC 3339, for example 2026-10-15T14:00:00Z")
335
+ p.add_argument("--end", required=True)
336
+ p.add_argument("--confirm", action="store_true", help="really reserve")
337
+ p.add_argument("--reveal-token", action="store_true", help="include the raw reservation token")
338
+ p = command(sub, "cancel", _cancel, lambda r, out: print("Cancelled.", file=out),
339
+ "cancel a reservation (needs --confirm)")
340
+ p.add_argument("reservation_id", help="as `remoterf reservations` shows it, or a JSON reservation_id")
341
+ p.add_argument("--confirm", action="store_true", help="really cancel")
342
+ command(sub, "doctor", _doctor, _show_doctor, "check this install, its home and its login")
343
+ return parser
344
+
345
+
346
+ def main(argv) -> int:
347
+ out = sys.stdout
348
+ as_json = "--json" in argv
349
+ try:
350
+ args = _parser().parse_args(argv)
351
+ as_json = args.json
352
+ with redirect_stdout(sys.stderr): # a stray library print must not reach stdout
353
+ result = args.run(args)
354
+ except RemoteRFOpsError as exc:
355
+ if as_json:
356
+ print(json.dumps(exc.to_dict()), file=out)
357
+ print(_clean(f"remoterf: {exc.message}"), file=sys.stderr)
358
+ return exc.exit_code
359
+ except KeyboardInterrupt:
360
+ print(file=sys.stderr)
361
+ return 130
362
+ except SystemExit as exc: # --help
363
+ return exc.code if isinstance(exc.code, int) else 0
364
+ if as_json:
365
+ print(json.dumps(result), file=out)
366
+ else:
367
+ for note in result.get("notes", ()):
368
+ print(_clean(note), file=sys.stderr)
369
+ args.show(result, out)
370
+ return 0