mru-cli 0.1.2__py3-none-any.whl

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.
mru_cli/__init__.py ADDED
@@ -0,0 +1 @@
1
+ """MRU terminal client."""
mru_cli/__main__.py ADDED
@@ -0,0 +1,3 @@
1
+ from .main import main
2
+
3
+ raise SystemExit(main())
mru_cli/main.py ADDED
@@ -0,0 +1,377 @@
1
+ """Terminal deployment flow for the MRU-owned physical bottle cell."""
2
+ import argparse
3
+ import getpass
4
+ import json
5
+ import os
6
+ from pathlib import Path
7
+ import subprocess
8
+ import sys
9
+ import tempfile
10
+ import time
11
+ from urllib.error import HTTPError, URLError
12
+ from urllib.parse import quote, urlparse
13
+ from urllib.request import HTTPRedirectHandler, Request, build_opener
14
+ import uuid
15
+
16
+ from .manifest import checkpoint_digest
17
+ from .project import init_project, load_project
18
+
19
+
20
+ class CliError(Exception):
21
+ pass
22
+
23
+
24
+ class NoRedirect(HTTPRedirectHandler):
25
+ def redirect_request(self, req, fp, code, msg, headers, newurl):
26
+ return None
27
+
28
+
29
+ def checked_url(url):
30
+ parsed = urlparse(url)
31
+ if (not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment
32
+ or parsed.path not in ("", "/")
33
+ or not (parsed.scheme == "https" or parsed.scheme == "http" and parsed.hostname in ("localhost", "127.0.0.1", "::1"))):
34
+ raise CliError("Use an HTTPS MRU website origin (HTTP is allowed only for localhost)")
35
+ return url.rstrip("/")
36
+
37
+
38
+ class Client:
39
+ def __init__(self, url, token):
40
+ self.url = checked_url(url)
41
+ if not token.startswith("mru_cli_"):
42
+ raise CliError("Create a CLI token in the Deployments dashboard, then run mru login")
43
+ self.token = token
44
+
45
+ def request(self, path, body=None):
46
+ data = json.dumps(body).encode() if body is not None else None
47
+ request = Request(self.url + "/api/cli" + path, data=data,
48
+ headers={"Authorization": "Bearer " + self.token, "Content-Type": "application/json"})
49
+ try:
50
+ with build_opener(NoRedirect).open(request, timeout=30) as response:
51
+ return json.load(response)
52
+ except HTTPError as exc:
53
+ try:
54
+ detail = json.load(exc).get("detail", f"HTTP {exc.code}")
55
+ except (ValueError, AttributeError):
56
+ detail = f"HTTP {exc.code}"
57
+ raise CliError(str(detail)) from exc
58
+ except (URLError, TimeoutError) as exc:
59
+ raise CliError("MRU connection failed; a submitted request may have been accepted. Reuse its request ID when retrying.") from exc
60
+
61
+
62
+ def config_path():
63
+ return Path(os.environ.get("MRU_CONFIG", str(Path.home() / ".config/mru/config.json")))
64
+
65
+
66
+ def read_config():
67
+ path = config_path()
68
+ return json.loads(path.read_text()) if path.exists() else {}
69
+
70
+
71
+ def save_config(value):
72
+ path = config_path()
73
+ path.parent.mkdir(parents=True, exist_ok=True, mode=0o700)
74
+ descriptor, temporary = tempfile.mkstemp(dir=path.parent, prefix=".mru-")
75
+ try:
76
+ with os.fdopen(descriptor, "w") as handle:
77
+ json.dump(value, handle)
78
+ os.replace(temporary, path)
79
+ finally:
80
+ Path(temporary).unlink(missing_ok=True)
81
+
82
+
83
+ def segment(value):
84
+ return quote(value, safe="")
85
+
86
+
87
+ def publish_inference(manifest, local_checkpoint=None):
88
+ import modal
89
+ if local_checkpoint:
90
+ print("Uploading checkpoint and processors…", file=sys.stderr)
91
+ volume = modal.Volume.from_name(manifest["volume"])
92
+ # The destination is derived from the contents, never a user's training path.
93
+ with volume.batch_upload(force=True) as batch:
94
+ batch.put_directory(str(local_checkpoint), manifest["checkpoint"].removeprefix("/store"))
95
+ if checkpoint_digest(local_checkpoint) != manifest["checkpointDigest"]:
96
+ raise CliError("Checkpoint changed during upload; save a stable checkpoint and deploy again")
97
+ with tempfile.TemporaryDirectory(prefix="mru-deploy-") as directory:
98
+ path = Path(directory) / "manifest.json"
99
+ path.write_text(json.dumps(manifest))
100
+ result = subprocess.run([sys.executable, "-m", "modal", "deploy", "-m",
101
+ "mru_cli.modal_inference"],
102
+ env={**os.environ, "MRU_BOTTLE_MANIFEST": str(path)}, stdout=sys.stderr, stderr=sys.stderr)
103
+ if result.returncode:
104
+ raise CliError("Inference publication failed. The deployment remains resumable; rerun the same deploy command.")
105
+
106
+
107
+ def wait_for(client, identifier, timeout):
108
+ deadline = time.monotonic() + timeout
109
+ previous = None
110
+ deployment = identifier.startswith("deployment:")
111
+ path = "/v1/" + ("deployments/" if deployment else "evaluations/") + segment(identifier)
112
+ while True:
113
+ result = client.request(path)
114
+ state = result["deployment"]["status"] if deployment else result["summary"]["status"]
115
+ message = result["deployment"].get("message", "") if deployment else next(
116
+ (item["run"].get("provenance", {}).get("message", "") for item in result.get("runs", [])), "")
117
+ activity = (state, message)
118
+ if activity != previous:
119
+ print(state.replace("_", " ") + (" · " + message if message else ""), file=sys.stderr)
120
+ previous = activity
121
+ if state in {"deployed", "completed", "failed", "cancelled"}:
122
+ return result
123
+ if time.monotonic() >= deadline:
124
+ raise CliError(f"Stopped waiting; the job is unchanged. Resume with: mru status {identifier} --watch")
125
+ time.sleep(3)
126
+
127
+
128
+ def output(value, as_json):
129
+ if as_json:
130
+ print(json.dumps(value, indent=2))
131
+ elif value.get("target") == "local" and "box" in value:
132
+ box = value["box"]
133
+ active = next((model for model in box.get("deployments", []) if model["id"] == box.get("active_id")), {})
134
+ print("Ethernet box: " + ("running" if box.get("running") else "stopped"))
135
+ print("Loaded model: " + active.get("name", "none"))
136
+ print("Robot contract: " + str(active.get("state_dim", "?")) + " state / " + str(active.get("action_dim", "?")) + " action values")
137
+ operation = value.get("operation") or {}
138
+ if operation:
139
+ progress = "Staging: " + operation.get("phase", "unknown").replace("_", " ")
140
+ if operation.get("totalBytes"):
141
+ progress += f" · {operation.get('receivedBytes', 0) / 1024**3:.2f} / {operation['totalBytes'] / 1024**3:.2f} GiB"
142
+ print(progress)
143
+ print("Client: " + ("ready" if value["client"]["ready"] else value["client"]["error"]))
144
+ elif "deployment" in value:
145
+ doc = value["deployment"]
146
+ print(f"{doc['id']} {doc['name']}\n{doc['status'].replace('_', ' ')} · {doc['message']}")
147
+ if "robot" in value:
148
+ print("Robot: " + value["robot"]["reason"])
149
+ elif "evaluation" in value:
150
+ print(f"{value['evaluation']['id']} {value['summary']['status']}")
151
+ for run in value["runs"]:
152
+ print(run["run"].get("provenance", {}).get("message", "Task outcome remains unverified"))
153
+ elif "deployments" in value:
154
+ for doc in value["deployments"]:
155
+ print(f"{doc['id']} {doc['status']:20} {doc['name']}")
156
+ if not value["deployments"]:
157
+ print("No deployments yet. Use mru deploy mru.json")
158
+ print("Robot: " + value["robot"]["reason"])
159
+ else:
160
+ print(json.dumps(value, indent=2))
161
+
162
+
163
+ def parser():
164
+ root = argparse.ArgumentParser(description=__doc__)
165
+ root.add_argument("--url", help="MRU website origin; defaults to MRU_URL or saved login")
166
+ root.add_argument("--json", action="store_true", help="Print machine-readable results")
167
+ commands = root.add_subparsers(dest="command", required=True)
168
+ commands.add_parser("login", help="Save a dashboard CLI token using a hidden prompt")
169
+ commands.add_parser("logout", help="Remove saved local credentials")
170
+ commands.add_parser("deployments", help="List checkpoint releases")
171
+ target = commands.add_parser("target", help="Select Modal or Ethernet inference for new deployments")
172
+ targets = target.add_subparsers(dest="target_command", required=True)
173
+ use = targets.add_parser("use", help="Save the default inference target on this computer")
174
+ use.add_argument("name", choices=("modal", "local"))
175
+ use.add_argument("--relay", help="Devbox relay origin, reached over Tailscale")
176
+ targets.add_parser("show", help="Show this computer's default target")
177
+ targets.add_parser("status", help="Inspect the Ethernet inference box through the Devbox")
178
+ targets.add_parser("logs", help="Show Ethernet inference worker logs")
179
+ upload = targets.add_parser("upload", help="Upload a checkpoint and run GPU-box warm inference without robot execution")
180
+ upload.add_argument("checkpoint", type=Path)
181
+ upload.add_argument("--name", help="Name in the Ethernet box model library")
182
+ pull = targets.add_parser("pull", help="Load an owned Modal release's checkpoint on the Ethernet box without a Mac upload")
183
+ pull.add_argument("deployment")
184
+ pull.add_argument("--name", help="Name in the Ethernet box model library")
185
+ init = commands.add_parser("init", help="Create a standalone robot job folder")
186
+ init.add_argument("directory", type=Path)
187
+ init.add_argument("--type", choices=("act", "pi05"), default="act", dest="policy_type")
188
+ init.add_argument("--volume", default="", help="Existing Modal checkpoint volume")
189
+ init.add_argument("--runtime-image", default="", help="Validated Modal runtime image ID")
190
+ init.add_argument("--rate-hz", type=int, choices=(30, 50), default=30)
191
+ check = commands.add_parser("check", help="Validate job settings and hash the checkpoint locally")
192
+ check.add_argument("manifest", type=Path, nargs="?", default=Path("."), help="Job folder or JSON manifest")
193
+ check.add_argument("--checkpoint", type=Path, help="Override the local checkpoint directory")
194
+ check.add_argument("--target", choices=("modal", "local"))
195
+ check.add_argument("--task", help="Exact training instruction for local pi05")
196
+ digest = commands.add_parser("digest", help="Hash a saved checkpoint, including processors")
197
+ digest.add_argument("directory", type=Path)
198
+ deploy = commands.add_parser("deploy", help="Upload, validate and queue a physical robot task")
199
+ deploy.add_argument("manifest", type=Path, nargs="?", default=Path("."), help="Job folder or physical inference JSON manifest (default: current folder)")
200
+ deploy.add_argument("--checkpoint", type=Path, help="Upload a complete saved local checkpoint")
201
+ deploy.add_argument("--target", choices=("modal", "local"), help="Override mru target use for this deployment")
202
+ deploy.add_argument("--task", help="Exact training instruction for local pi05")
203
+ deploy.add_argument("--wait", action="store_true", help="Watch the robot task through completion")
204
+ deploy.add_argument("--no-run", action="store_true", help="Publish and validate only; do not queue robot execution")
205
+ deploy.add_argument("--auto", action="store_true", help="Authorize this job to execute without a local AUTO prompt")
206
+ deploy.add_argument("--request-id", help="Reuse after an uncertain deployment submission to avoid a duplicate task")
207
+ validate = commands.add_parser("validate", help="Retry loading an already published release")
208
+ validate.add_argument("identifier")
209
+ run = commands.add_parser("run", help="Queue another robot task from a published release")
210
+ run.add_argument("identifier")
211
+ run.add_argument("--request-id", help="Reuse this value after an uncertain submission")
212
+ run.add_argument("--wait", action="store_true")
213
+ run.add_argument("--auto", action="store_true", help="Authorize this job to execute without a local AUTO prompt")
214
+ status = commands.add_parser("status", help="Inspect a deployment or evaluation")
215
+ status.add_argument("identifier")
216
+ status.add_argument("--watch", action="store_true")
217
+ logs = commands.add_parser("logs", help="Show persisted deployment activity and run messages")
218
+ logs.add_argument("identifier")
219
+ cancel = commands.add_parser("cancel", help="Request stop for an evaluation; local stop confirmation is still required")
220
+ cancel.add_argument("identifier")
221
+ for command in (deploy, run, status):
222
+ command.add_argument("--timeout", type=int, default=900, help="Maximum wait in seconds")
223
+ return root
224
+
225
+
226
+ def execute(args):
227
+ config = read_config()
228
+ if args.command == "target" and args.target_command in {"use", "show"}:
229
+ from .targets import DEFAULT_RELAY, relay_origin
230
+ if args.target_command == "use":
231
+ config["inferenceTarget"] = args.name
232
+ if args.relay:
233
+ config["localRelay"] = relay_origin(args.relay)
234
+ save_config(config)
235
+ print(json.dumps({"target": config.get("inferenceTarget", "modal"),
236
+ "localRelay": config.get("localRelay", DEFAULT_RELAY)}))
237
+ return 0
238
+ if args.command == "deploy" and args.auto and args.no_run:
239
+ raise CliError("--auto cannot be combined with --no-run")
240
+ if args.command == "init":
241
+ directory = init_project(args.directory, policy_type=args.policy_type, volume=args.volume,
242
+ runtime_image=args.runtime_image, rate_hz=args.rate_hz)
243
+ if args.json:
244
+ print(json.dumps({"directory": str(directory), "manifest": str(directory / "mru.json")}))
245
+ else:
246
+ print(f"Created robot job at {directory}\nAdd your saved checkpoint to checkpoint/ and configure mru.json.\nThen run mru check from the job folder.")
247
+ return 0
248
+ if args.command == "check":
249
+ manifest, checkpoint = load_project(args.manifest, args.checkpoint,
250
+ target=args.target or config.get("inferenceTarget"), task=args.task)
251
+ if args.json:
252
+ print(json.dumps({"manifest": manifest, "localCheckpoint": str(checkpoint.resolve()) if checkpoint else None}, indent=2))
253
+ else:
254
+ print(f"Job settings valid: {manifest['name']} ({manifest['type']})\nCheckpoint SHA-256: {manifest['checkpointDigest']}\nLocal checks only; model loading and robot compatibility still require deployment validation.")
255
+ return 0
256
+ if args.command == "digest":
257
+ print(checkpoint_digest(args.directory))
258
+ return 0
259
+ if args.command == "logout":
260
+ config_path().unlink(missing_ok=True)
261
+ print("Local credentials removed. Revoke the token in the dashboard to invalidate it.")
262
+ return 0
263
+ url = args.url or os.environ.get("MRU_URL") or config.get("url", "https://app.makerobotsuseful.com")
264
+ token = os.environ.get("MRU_TOKEN") or config.get("token", "")
265
+ if args.command == "login":
266
+ token = getpass.getpass("CLI token from the Deployments dashboard: ").strip()
267
+ client = Client(url, token)
268
+ if args.command == "login":
269
+ client.request("/v1/me")
270
+ save_config({**config, "url": client.url, "token": token})
271
+ print("Connected to " + client.url)
272
+ return 0
273
+ if args.command == "deploy":
274
+ manifest, checkpoint = load_project(args.manifest, args.checkpoint,
275
+ target=args.target or config.get("inferenceTarget"), task=args.task)
276
+ local = manifest.get("inferenceTarget") == "local"
277
+ if local:
278
+ from .targets import LocalTarget, DEFAULT_RELAY
279
+ local_target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
280
+ readiness = local_target.request("/v1/status")["client"]
281
+ if not readiness["ready"]:
282
+ raise CliError("Local inference client is not ready: " + readiness["error"])
283
+ if checkpoint is None and not local_target.request("/v1/staged/" + manifest["checkpointDigest"])["available"]:
284
+ raise CliError("Checkpoint is not staged on the Ethernet box; use --checkpoint or mru target pull first")
285
+ request_id = args.request_id or uuid.uuid4().hex
286
+ if not args.no_run:
287
+ print(f"Request ID: {request_id} (reuse with --request-id when retrying this deploy)", file=sys.stderr)
288
+ print("This job is authorized to start physical execution when the robot is ready." if args.auto else
289
+ "This deployment queues a physical task; it runs when local automatic mode is enabled.", file=sys.stderr)
290
+ result = client.request("/v1/deployments", manifest)
291
+ doc = result["deployment"]
292
+ identifier = doc["id"]
293
+ print(identifier, file=sys.stderr)
294
+ print(client.url + "/deployments?deployment=" + segment(identifier), file=sys.stderr)
295
+ if not local and doc["status"] not in {"deployed", "validating"}:
296
+ publish_inference(doc["manifest"], checkpoint)
297
+ result = client.request("/v1/deployments/" + segment(identifier) + "/validate", {})
298
+ if local and result["deployment"]["status"] != "deployed":
299
+ print("Publishing checkpoint to the Ethernet GPU box through the resident Devbox relay…", file=sys.stderr)
300
+ local_target.publish(identifier, checkpoint, manifest["checkpointDigest"])
301
+ result = client.request("/v1/deployments/" + segment(identifier))
302
+ if args.wait or not args.no_run:
303
+ result = wait_for(client, identifier, args.timeout)
304
+ if not args.no_run and result["deployment"]["status"] == "deployed":
305
+ result = client.request("/v1/deployments/" + segment(identifier) + "/runs",
306
+ {"requestId": request_id, **({"auto": True} if args.auto else {})})
307
+ print(result["evaluation"]["id"], file=sys.stderr)
308
+ if args.wait:
309
+ result = wait_for(client, result["evaluation"]["id"], args.timeout)
310
+ elif args.command == "target":
311
+ from .targets import LocalTarget, DEFAULT_RELAY
312
+ target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
313
+ if args.target_command == "upload":
314
+ result = target.stage(args.checkpoint, args.name)
315
+ elif args.target_command == "pull":
316
+ print("Devbox is fetching the saved checkpoint, then loading and warming the Ethernet GPU box…", file=sys.stderr)
317
+ print("Progress: mru target status", file=sys.stderr)
318
+ result = target.pull(args.deployment, args.name)
319
+ else:
320
+ result = target.request("/v1/" + args.target_command)
321
+ elif args.command == "deployments":
322
+ result = client.request("/v1/deployments")
323
+ elif args.command == "validate":
324
+ result = client.request("/v1/deployments/" + segment(args.identifier) + "/validate", {})
325
+ doc = result["deployment"]
326
+ if doc["manifest"].get("inferenceTarget") == "local" and doc["status"] != "deployed":
327
+ from .targets import LocalTarget, DEFAULT_RELAY
328
+ target = LocalTarget(token, config.get("localRelay", DEFAULT_RELAY))
329
+ target.publish(args.identifier, None, doc["manifest"]["checkpointDigest"])
330
+ result = client.request("/v1/deployments/" + segment(args.identifier))
331
+ elif args.command == "run":
332
+ request_id = args.request_id or uuid.uuid4().hex
333
+ print(f"Request ID: {request_id} (reuse with --request-id if the response is lost)", file=sys.stderr)
334
+ if args.auto:
335
+ print("This job is authorized to start physical execution when the robot is ready.", file=sys.stderr)
336
+ result = client.request("/v1/deployments/" + segment(args.identifier) + "/runs",
337
+ {"requestId": request_id, **({"auto": True} if args.auto else {})})
338
+ if args.wait:
339
+ print(result["evaluation"]["id"], file=sys.stderr)
340
+ result = wait_for(client, result["evaluation"]["id"], args.timeout)
341
+ elif args.command == "cancel":
342
+ result = client.request("/v1/evaluations/" + segment(args.identifier) + "/cancel", {})
343
+ else:
344
+ if args.command == "status" and args.watch:
345
+ result = wait_for(client, args.identifier, args.timeout)
346
+ else:
347
+ resource = "deployments" if args.identifier.startswith("deployment:") else "evaluations"
348
+ result = client.request(f"/v1/{resource}/" + segment(args.identifier))
349
+ if args.command == "logs":
350
+ entries = list(result.get("deployment", {}).get("events", []))
351
+ evaluations = result.get("evaluations", [result] if "evaluation" in result else [])
352
+ for view in evaluations:
353
+ for item in view["runs"]:
354
+ run = item["run"]
355
+ entries.append({"at": run.get("endedAt") or run["createdAt"],
356
+ "message": run["status"] + " · " + run.get("provenance", {}).get("message", "Task outcome unverified")})
357
+ if args.json:
358
+ print(json.dumps(entries, indent=2))
359
+ else:
360
+ for entry in entries:
361
+ print(f"{entry['at']} {entry['message']}")
362
+ return 0
363
+ output(result, args.json)
364
+ status = result.get("deployment", {}).get("status") or result.get("summary", {}).get("status")
365
+ return 1 if status in {"failed", "cancelled"} else 0
366
+
367
+
368
+ def main(argv=None):
369
+ args = parser().parse_args(argv)
370
+ try:
371
+ return execute(args)
372
+ except (CliError, ValueError, OSError) as exc:
373
+ print(f"mru: {exc}", file=sys.stderr)
374
+ return 1
375
+ except KeyboardInterrupt:
376
+ print("Stopped waiting; inspect the deployment or run before submitting again.", file=sys.stderr)
377
+ return 130
mru_cli/manifest.py ADDED
@@ -0,0 +1,84 @@
1
+ """Shared, dependency-free contract for physical checkpoint releases."""
2
+ import hashlib
3
+ import json
4
+ from pathlib import Path, PurePosixPath
5
+ import re
6
+
7
+ DEPLOYMENT_PROTOCOL = "mru.physical-deployment/v1"
8
+ MAX_CYCLES = 30
9
+ JOINT_ORDER = ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]]
10
+
11
+
12
+ def require_policy_attestation(policy, reply):
13
+ if not isinstance(reply, dict):
14
+ raise ValueError("Inference attestation must be an object")
15
+ if policy.get("manifestDigest") and reply.get("manifestDigest") != policy["manifestDigest"]:
16
+ raise ValueError("Inference release does not match the deployed manifest")
17
+ if reply.get("checkpointDigest") != policy["checkpointDigest"] or reply.get("fps") != policy["rateHz"]:
18
+ raise ValueError("Inference checkpoint/control rate does not match assignment")
19
+ if reply.get("joint_order") != JOINT_ORDER:
20
+ raise ValueError("Inference joint ordering mismatch")
21
+
22
+
23
+ def canonical(value):
24
+ return json.dumps(value, sort_keys=True, separators=(",", ":"), allow_nan=False)
25
+
26
+
27
+ def manifest_digest(manifest):
28
+ return hashlib.sha256(canonical(manifest).encode()).hexdigest()
29
+
30
+
31
+ def validate_manifest(value):
32
+ if not isinstance(value, dict):
33
+ raise ValueError("Manifest must be a JSON object")
34
+ local = value.get("inferenceTarget") == "local"
35
+ required = {"name", "type", "checkpoint", "checkpointDigest", "rateHz"}
36
+ required |= {"inferenceTarget", "task"} if local else {"volume", "runtimeImageId"}
37
+ if not required <= value.keys() or value.keys() - required - {"cycles"}:
38
+ raise ValueError("Manifest requires " + ", ".join(sorted(required)) + "; cycles is optional")
39
+ if not isinstance(value["name"], str) or not 1 <= len(value["name"].strip()) <= 80:
40
+ raise ValueError("name must contain 1–80 characters")
41
+ if value["type"] not in ("act", "pi05"):
42
+ raise ValueError("Only physical bottle ACT and pi05 checkpoints are supported")
43
+ if local and (value["type"] != "pi05" or not isinstance(value["task"], str)
44
+ or not 1 <= len(value["task"].strip()) <= 2000):
45
+ raise ValueError("The local target requires pi05 and its training task instruction")
46
+ patterns = [("checkpointDigest", r"[a-f0-9]{64}")]
47
+ if not local:
48
+ patterns += [("volume", r"[A-Za-z0-9][A-Za-z0-9_-]{0,63}"), ("runtimeImageId", r"im-[A-Za-z0-9]+")]
49
+ for key, pattern in patterns:
50
+ if not isinstance(value[key], str) or not re.fullmatch(pattern, value[key]):
51
+ raise ValueError(f"Invalid {key}")
52
+ checkpoint = value["checkpoint"]
53
+ if (not isinstance(checkpoint, str) or not checkpoint.startswith("/store/")
54
+ or any(part in (".", "..") for part in checkpoint.split("/"))
55
+ or str(PurePosixPath(checkpoint)) != checkpoint or len(checkpoint) > 500):
56
+ raise ValueError("checkpoint must be a normalized directory under /store")
57
+ if type(value["rateHz"]) is not int or value["rateHz"] not in (30, 50):
58
+ raise ValueError("rateHz must be the checkpoint's learned rate: 30 or 50")
59
+ cycles = value.get("cycles", 5)
60
+ if type(cycles) is not int or not 1 <= cycles <= MAX_CYCLES:
61
+ raise ValueError(f"cycles must be between 1 and {MAX_CYCLES}")
62
+ return {**value, "name": value["name"].strip(), "cycles": cycles}
63
+
64
+
65
+ def checkpoint_digest(directory):
66
+ directory = Path(directory)
67
+ if directory.is_symlink() or not directory.is_dir():
68
+ raise ValueError("Checkpoint must be a real directory")
69
+ result = hashlib.sha256()
70
+ files = 0
71
+ for path in sorted(directory.rglob("*")):
72
+ if path.is_symlink():
73
+ raise ValueError("Checkpoint symlinks are not supported")
74
+ if not path.is_file():
75
+ continue
76
+ files += 1
77
+ digest = hashlib.sha256()
78
+ with path.open("rb") as handle:
79
+ for block in iter(lambda: handle.read(1024 * 1024), b""):
80
+ digest.update(block)
81
+ result.update(path.relative_to(directory).as_posix().encode() + b"\0" + digest.digest())
82
+ if not files:
83
+ raise ValueError("Checkpoint directory is empty")
84
+ return result.hexdigest()
@@ -0,0 +1,122 @@
1
+ """Deploy a separately pinned bottle checkpoint; returns predictions only.
2
+
3
+ Set MRU_BOTTLE_MANIFEST to an immutable local JSON manifest before modal deploy.
4
+ This app has no device credential, robot connection or training entrypoint.
5
+ """
6
+ import json
7
+ import os
8
+ from pathlib import Path
9
+
10
+ import modal
11
+ from mru_cli.manifest import DEPLOYMENT_PROTOCOL, checkpoint_digest, manifest_digest
12
+
13
+ MANIFEST_PATH = Path(os.environ.get("MRU_BOTTLE_MANIFEST", "/opt/bottle-manifest.json"))
14
+ MANIFEST = json.loads(MANIFEST_PATH.read_text())
15
+ if MANIFEST["type"] not in {"act", "pi05"} or MANIFEST["rateHz"] not in (30, 50):
16
+ raise ValueError("Manifest requires ACT/pi05 and exact learned control rate")
17
+ app = modal.App(MANIFEST["modalApp"])
18
+ volume = modal.Volume.from_name(MANIFEST["volume"])
19
+ image = (modal.Image.from_id(MANIFEST["runtimeImageId"])
20
+ .env({"TOKENIZERS_PARALLELISM": "false"})
21
+ .add_local_python_source("mru_cli", copy=True)
22
+ .add_local_file(MANIFEST_PATH, "/opt/bottle-manifest.json", copy=True))
23
+
24
+
25
+ @app.cls(image=image, gpu="L4", cpu=4 if MANIFEST["type"] == "pi05" else 2,
26
+ memory=32768 if MANIFEST["type"] == "pi05" else 16384, volumes={"/store": volume},
27
+ secrets=[modal.Secret.from_name("huggingface-secret")],
28
+ timeout=600 if MANIFEST["type"] == "pi05" else 300, scaledown_window=300, max_containers=1,
29
+ retries=0)
30
+ class Predictor:
31
+ @modal.enter()
32
+ def load(self):
33
+ import site
34
+ site.addsitedir("/opt/anvil/.venv/lib/python3.12/site-packages")
35
+ import torch
36
+ from safetensors.torch import load_file
37
+ from lerobot.configs.policies import PreTrainedConfig
38
+ from lerobot.processor import PolicyProcessorPipeline
39
+
40
+ volume.reload()
41
+ checkpoint = Path(MANIFEST["checkpoint"])
42
+ if not checkpoint.is_relative_to("/store") or not checkpoint.is_dir():
43
+ raise ValueError("Checkpoint must be a saved directory on the mounted volume")
44
+ if checkpoint_digest(checkpoint) != MANIFEST["checkpointDigest"]:
45
+ raise ValueError("Checkpoint/processor digest does not match the pinned manifest")
46
+ # LeRobot registers each policy's config when its module is imported.
47
+ # Register the selected family before decoding the saved config.
48
+ if MANIFEST["type"] == "pi05":
49
+ import lerobot.policies.pi05.configuration_pi05
50
+ else:
51
+ import lerobot.policies.act.configuration_act
52
+ config = PreTrainedConfig.from_pretrained(checkpoint)
53
+ if config.type != MANIFEST["type"]:
54
+ raise ValueError("Checkpoint policy type mismatch")
55
+ config.device = "cuda"
56
+ if config.type == "pi05":
57
+ from lerobot.policies.pi05 import PI05Policy
58
+ import lerobot.policies.pi05.processor_pi05
59
+ config.compile_model = False
60
+ names = ["right_finger_joint1", *[f"right_joint{i}" for i in range(1, 8)]]
61
+ if config.action_feature_names != names or config.use_relative_actions:
62
+ raise ValueError("PI05 must emit absolute right-arm positions in gripper-first order")
63
+ self.model = PI05Policy(config)
64
+ weights = load_file(str(checkpoint / "model.safetensors"))
65
+ weights = self.model._fix_pytorch_state_dict_keys(weights, config)
66
+ weights = {key if key.startswith("model.") else "model." + key: value for key, value in weights.items()}
67
+ else:
68
+ from lerobot.policies.act.modeling_act import ACTPolicy
69
+ import lerobot.policies.act.processor_act
70
+ self.model = ACTPolicy(config)
71
+ weights = load_file(str(checkpoint / "model.safetensors"))
72
+ self.model.load_state_dict(weights, strict=True)
73
+ del weights
74
+ self.model = self.model.to("cuda").eval()
75
+ self.pre = PolicyProcessorPipeline.from_pretrained(checkpoint, config_filename="policy_preprocessor.json")
76
+ self.post = PolicyProcessorPipeline.from_pretrained(checkpoint, config_filename="policy_postprocessor.json")
77
+ self.image_shapes = {key: feature.shape for key, feature in config.input_features.items() if key.startswith("observation.images.")}
78
+ expected = {"observation.images." + name for name in ("waist", "wrist_r", "chest")}
79
+ if set(self.image_shapes) != expected or tuple(config.output_features["action"].shape) != (8,):
80
+ raise ValueError("Checkpoint must match the physical bottle camera/eight-joint contract")
81
+
82
+ @modal.method()
83
+ def describe(self):
84
+ return {"checkpointDigest": MANIFEST["checkpointDigest"], "fps": MANIFEST["rateHz"],
85
+ "manifestDigest": manifest_digest(MANIFEST), "deploymentProtocol": DEPLOYMENT_PROTOCOL,
86
+ "joint_order": ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]],
87
+ "checkpoint": MANIFEST["checkpoint"], "task_outcome": "unverified"}
88
+
89
+ @modal.method()
90
+ def predict(self, state: list[float], images: dict[str, bytes]):
91
+ import io
92
+ import time
93
+ import numpy as np
94
+ import torch
95
+ from PIL import Image, ImageOps
96
+
97
+ start = time.perf_counter()
98
+ array = np.asarray(state, dtype=np.float32)
99
+ if array.shape != (8,) or not np.isfinite(array).all() or set(images) != {"waist", "wrist_r", "chest"}:
100
+ raise ValueError("Expected eight finite gripper-first positions and three bottle cameras")
101
+ obs = {"observation.state": torch.from_numpy(array), "task": ["Pick up the bottle and hold it above the table."]}
102
+ for name, data in images.items():
103
+ if len(data) > 2_000_000:
104
+ raise ValueError("Camera payload exceeds 2 MB")
105
+ _, height, width = self.image_shapes["observation.images." + name]
106
+ with Image.open(io.BytesIO(data)) as source:
107
+ rgb = ImageOps.pad(source.convert("RGB"), (width, height), method=Image.Resampling.BILINEAR)
108
+ image = np.asarray(rgb).copy()
109
+ obs["observation.images." + name] = torch.from_numpy(image).permute(2, 0, 1).float() / 255
110
+ with torch.inference_mode():
111
+ torch.manual_seed(42)
112
+ batch = self.pre(obs)
113
+ batch = {key: value.to("cuda") if torch.is_tensor(value) else value for key, value in batch.items()}
114
+ chunk = self.model.predict_action_chunk(batch)
115
+ actions = torch.stack([self.post.process_action(chunk[:, index]) for index in range(chunk.shape[1])], dim=1)
116
+ result = actions.squeeze(0).cpu().numpy()
117
+ if result.ndim != 2 or result.shape[1] != 8 or not 1 <= result.shape[0] <= 100 or not np.isfinite(result).all():
118
+ raise RuntimeError("Invalid physical action chunk")
119
+ return {"checkpointDigest": MANIFEST["checkpointDigest"], "fps": MANIFEST["rateHz"],
120
+ "manifestDigest": manifest_digest(MANIFEST),
121
+ "joint_order": ["finger_joint1", *[f"joint{i}" for i in range(1, 8)]],
122
+ "actions": result.tolist(), "inference_ms": (time.perf_counter() - start) * 1000}
mru_cli/project.py ADDED
@@ -0,0 +1,92 @@
1
+ """Portable job directories for the built-in physical policy runner."""
2
+ import json
3
+ from pathlib import Path
4
+
5
+ from .manifest import checkpoint_digest, validate_manifest
6
+
7
+
8
+ JOB_README = """# MRU robot job
9
+
10
+ This project uses MRU's built-in ACT/pi0.5 inference runner. You do not need to
11
+ write Python inference code. The current task is the supported OpenARM bottle
12
+ trial; custom tasks and inference scripts are not loaded from this folder.
13
+
14
+ 1. Copy your complete saved LeRobot checkpoint into `checkpoint/`, including
15
+ `config.json`, `model.safetensors`, saved pre/postprocessors and their files.
16
+ 2. Edit `mru.json`: set the existing Modal `volume` and validated
17
+ `runtimeImageId`, and match `type` and `rateHz` to your trained checkpoint.
18
+ `cycles` counts observe/predict/execute-chunk cycles within one trial.
19
+ 3. From this folder, run:
20
+
21
+ ```bash
22
+ mru check .
23
+ mru login
24
+ mru deploy . --wait
25
+ ```
26
+
27
+ You can also use `mru deploy /path/to/this-folder` from any directory.
28
+ The MRU CLI must already be installed, and Modal configured for the MRU workspace.
29
+ Local checks validate the manifest and hash files; model loading happens on Modal.
30
+ Deployment validates the checkpoint and queues its robot task. Once an operator
31
+ enables automatic mode locally at the workcell, uploaded jobs execute without
32
+ a per-job ARM prompt. Disabled/offline/busy workcells leave jobs in the queue.
33
+ Use `mru deploy . --no-run` to publish a release without requesting execution.
34
+
35
+ Only the configured checkpoint directory is uploaded. Source files, this README,
36
+ and other project files are not uploaded. Keep credentials out of the checkpoint.
37
+ `checkpointDir` is relative to `mru.json`; no absolute machine paths are needed.
38
+ The included `.gitignore` keeps weights and local secrets out of Git. Share the
39
+ project configuration through Git and supply checkpoint files separately.
40
+ """
41
+
42
+
43
+ def init_project(directory, *, policy_type="act", volume="", runtime_image="", rate_hz=30):
44
+ directory = Path(directory)
45
+ if directory.exists() and (not directory.is_dir() or any(directory.iterdir())):
46
+ raise ValueError("Choose a new or empty directory; mru init does not overwrite existing files")
47
+ directory.mkdir(parents=True, exist_ok=True)
48
+ manifest = {"name": directory.resolve().name, "type": policy_type,
49
+ "checkpointDir": "./checkpoint", "volume": volume,
50
+ "runtimeImageId": runtime_image, "rateHz": rate_hz, "cycles": 5}
51
+ (directory / "mru.json").write_text(json.dumps(manifest, indent=2) + "\n")
52
+ (directory / "README.md").write_text(JOB_README)
53
+ (directory / ".gitignore").write_text("/checkpoint/\n.env\n.env.*\n.venv/\n__pycache__/\n.DS_Store\n")
54
+ (directory / "checkpoint").mkdir()
55
+ return directory.resolve()
56
+
57
+
58
+ def load_project(source, checkpoint_override=None, *, target=None, task=None):
59
+ source = Path(source)
60
+ manifest_path = source / "mru.json" if source.is_dir() else source
61
+ raw = json.loads(manifest_path.read_text())
62
+ if not isinstance(raw, dict):
63
+ raise ValueError("Manifest must be a JSON object")
64
+ if target is not None:
65
+ if target == "local":
66
+ raw["inferenceTarget"] = "local"
67
+ raw.pop("volume", None)
68
+ raw.pop("runtimeImageId", None)
69
+ elif target == "modal":
70
+ raw.pop("inferenceTarget", None)
71
+ raw.pop("task", None)
72
+ else:
73
+ raise ValueError("Inference target must be modal or local")
74
+ if task is not None:
75
+ raw["task"] = task
76
+ relative = raw.pop("checkpointDir", None)
77
+ checkpoint = Path(checkpoint_override) if checkpoint_override is not None else None
78
+ if relative is not None and checkpoint is None:
79
+ if not isinstance(relative, str) or not relative.strip():
80
+ raise ValueError("checkpointDir must name a directory inside the job folder")
81
+ path = Path(relative)
82
+ if path.is_absolute() or ".." in path.parts or not path.parts:
83
+ raise ValueError("checkpointDir must name a directory inside the job folder")
84
+ root = manifest_path.parent.resolve()
85
+ checkpoint = root / path
86
+ if root not in checkpoint.resolve().parents or any(
87
+ (root / Path(*path.parts[:index])).is_symlink() for index in range(1, len(path.parts) + 1)):
88
+ raise ValueError("checkpointDir must stay inside the job folder without symlinks")
89
+ if checkpoint is not None:
90
+ digest = checkpoint_digest(checkpoint)
91
+ raw.update(checkpointDigest=digest, checkpoint="/store/mru/checkpoints/" + digest)
92
+ return validate_manifest(raw), checkpoint
mru_cli/targets.py ADDED
@@ -0,0 +1,106 @@
1
+ """Terminal access to the resident Devbox's fixed Ethernet inference target."""
2
+ import http.client
3
+ import ipaddress
4
+ import json
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ import re
8
+ import sys
9
+ import time
10
+ import tarfile
11
+ import tempfile
12
+ from urllib.parse import urlparse, quote
13
+ from .manifest import checkpoint_digest
14
+
15
+
16
+ DEFAULT_RELAY = "http://100.122.87.127:8092"
17
+
18
+
19
+ class UploadStream:
20
+ def __init__(self, handle, total):
21
+ self.handle, self.total = handle, total
22
+ self.sent, self.last = 0, time.monotonic()
23
+
24
+ def read(self, size):
25
+ data = self.handle.read(size)
26
+ self.sent += len(data)
27
+ now = time.monotonic()
28
+ if now - self.last >= 15 or not data:
29
+ print(f"Uploaded {self.sent / 1024**3:.2f} / {self.total / 1024**3:.2f} GiB to Devbox", file=sys.stderr)
30
+ self.last = now
31
+ return data
32
+
33
+
34
+ @contextmanager
35
+ def packed_checkpoint(checkpoint):
36
+ with tempfile.TemporaryDirectory(prefix="mru-local-") as directory:
37
+ archive = Path(directory) / "checkpoint.tar"
38
+ print("Packing checkpoint for Ethernet upload…", file=sys.stderr)
39
+ with tarfile.open(archive, "w", dereference=False) as output:
40
+ output.add(checkpoint, arcname="pretrained_model")
41
+ print(f"Uploading {archive.stat().st_size / 1024**3:.2f} GiB through the Devbox…", file=sys.stderr)
42
+ yield archive
43
+
44
+
45
+ def relay_origin(value):
46
+ parsed = urlparse(value)
47
+ try:
48
+ address = ipaddress.ip_address(parsed.hostname or "")
49
+ trusted_http = address.is_loopback or address in ipaddress.ip_network("100.64.0.0/10")
50
+ except ValueError:
51
+ trusted_http = parsed.hostname == "localhost"
52
+ if (not parsed.hostname or parsed.username or parsed.password or parsed.query or parsed.fragment
53
+ or parsed.path not in ("", "/") or parsed.scheme not in {"https", "http"}
54
+ or parsed.scheme == "http" and not trusted_http):
55
+ raise ValueError("Use an HTTPS relay, or HTTP over Tailscale/localhost")
56
+ return value.rstrip("/")
57
+
58
+
59
+ class LocalTarget:
60
+ def __init__(self, token, url=DEFAULT_RELAY):
61
+ self.url, self.token = relay_origin(url), token
62
+
63
+ def request(self, path, archive=None, body=None):
64
+ parsed = urlparse(self.url)
65
+ connection_type = http.client.HTTPSConnection if parsed.scheme == "https" else http.client.HTTPConnection
66
+ connection = connection_type(parsed.hostname, parsed.port, timeout=7200 if archive or body is not None else 30, blocksize=1024 * 1024)
67
+ try:
68
+ headers = {"Authorization": "Bearer " + self.token}
69
+ if archive:
70
+ headers.update({"Content-Type": "application/x-tar", "Content-Length": str(archive.stat().st_size)})
71
+ with archive.open("rb") as handle:
72
+ connection.request("POST", path, body=UploadStream(handle, archive.stat().st_size), headers=headers)
73
+ else:
74
+ payload = json.dumps(body).encode() if body is not None else None
75
+ headers["Content-Type"] = "application/json"
76
+ connection.request("POST" if body is not None else "GET", path, body=payload, headers=headers)
77
+ response = connection.getresponse()
78
+ result = json.loads(response.read())
79
+ if response.status >= 400:
80
+ raise ValueError(result.get("detail", "Local inference request failed"))
81
+ return result
82
+ except (OSError, http.client.HTTPException) as error:
83
+ raise ValueError("Devbox inference relay unavailable. Inspect mru target status before retrying; publication may have been accepted.") from error
84
+ finally:
85
+ connection.close()
86
+
87
+ def publish(self, deployment, checkpoint, digest=None):
88
+ path = "/v1/deployments/" + quote(deployment, safe="") + "/publish"
89
+ if digest and self.request("/v1/staged/" + digest)["available"]:
90
+ print("Reusing the verified checkpoint already on the Ethernet box…", file=sys.stderr)
91
+ return self.request(path, body={"reuseStaged": True})
92
+ if checkpoint is None:
93
+ raise ValueError("Checkpoint is not staged; supply --checkpoint /path/to/pretrained_model")
94
+ with packed_checkpoint(checkpoint) as archive:
95
+ return self.request(path, archive)
96
+
97
+ def stage(self, checkpoint, name=None):
98
+ digest = checkpoint_digest(checkpoint)
99
+ name = name or "mru-" + digest[:24]
100
+ if not re.fullmatch(r"[A-Za-z0-9][A-Za-z0-9._-]{0,63}", name):
101
+ raise ValueError("Model name must contain 1–64 letters, numbers, dots, hyphens or underscores")
102
+ with packed_checkpoint(checkpoint) as archive:
103
+ return self.request("/v1/stage?digest=" + digest + "&name=" + quote(name, safe=""), archive)
104
+
105
+ def pull(self, deployment, name=None):
106
+ return self.request("/v1/pull/" + quote(deployment, safe=""), body={"name": name} if name else {})
@@ -0,0 +1,92 @@
1
+ Metadata-Version: 2.4
2
+ Name: mru-cli
3
+ Version: 0.1.2
4
+ Summary: Deploy pinned robot checkpoints and track MRU physical runs
5
+ Project-URL: Repository, https://github.com/Druzrka/mru-cli
6
+ Requires-Python: >=3.11
7
+ Description-Content-Type: text/markdown
8
+ Requires-Dist: modal<2,>=1.0
9
+
10
+ # MRU CLI
11
+
12
+ The standalone command-line client for [Make Robots Useful](https://app.makerobotsuseful.com).
13
+ It talks to the MRU API using your account token. Installation and login do not
14
+ require a checkpoint, a robot connection, or a Modal account.
15
+
16
+ ## Install from this checkout
17
+
18
+ Requires Python 3.11 or newer. Clone this repository, then run:
19
+
20
+ ```bash
21
+ python3 -m venv .venv
22
+ .venv/bin/python -m pip install .
23
+ .venv/bin/mru --help
24
+ .venv/bin/mru --url https://app.makerobotsuseful.com login
25
+ ```
26
+
27
+ Create an account token at [Connect](https://app.makerobotsuseful.com/#connect)
28
+ and enter it in the hidden login prompt. Login verifies your identity through
29
+ `/v1/me` and saves credentials with mode `0600` in `~/.config/mru/config.json`.
30
+ Do not put tokens in chat, Git, or command arguments. `mru logout` removes the
31
+ local credentials; revoke tokens in the dashboard.
32
+
33
+ ```bash
34
+ mru --url https://app.makerobotsuseful.com --json deployments
35
+ ```
36
+
37
+ Robot access is assigned by MRU separately. A new account can log in without
38
+ being assigned a robot. Network restrictions in an agent's environment must
39
+ permit the MRU API domain even when package installation succeeds.
40
+
41
+ ## Upload a trained checkpoint
42
+
43
+ ACT (`act`) and pi0.5 (`pi05`) are supported checkpoint formats, not models
44
+ included with an account. Use your own complete trained checkpoint, including
45
+ weights, config, preprocessor and postprocessor. Cloud publication also needs
46
+ the assigned Modal workspace/environment, an existing volume, and a validated
47
+ runtime image. The control rate must match training.
48
+
49
+ Use `mru init --help`, `mru check --help`, and `mru deploy --help` for project
50
+ settings. `mru deploy ... --no-run` publishes and validates without queuing a
51
+ physical trial. Running a trial requires explicit authorization; plain
52
+ `mru deploy` also queues a run.
53
+
54
+ `mru target` manages optional local inference through the commissioned Devbox
55
+ relay. It is not required for CLI installation or login. The CLI itself does
56
+ not contain the robot operator services.
57
+
58
+ ## Develop and test
59
+
60
+ ```bash
61
+ .venv/bin/python -m pip install -e .
62
+ .venv/bin/python -m unittest discover -s tests -v
63
+ ```
64
+
65
+ The client manifest contract in `mru_cli/manifest.py` implements
66
+ `mru.physical-deployment/v1`. Its server counterpart lives in
67
+ `mru-web/backend/deployment_manifest.py`. Contract changes require compatibility
68
+ tests on both sides; moving repositories does not change the wire protocol.
69
+
70
+ ## Build and publish
71
+
72
+ Version 0.1.2 moves the inference wrapper into `mru_cli.modal_inference`, removing
73
+ the dependency on the web repository's `integrations` namespace.
74
+
75
+ ```bash
76
+ .venv/bin/python scripts/build_release.py
77
+ shasum -a 256 releases/mru_cli-0.1.2-py3-none-any.whl
78
+ ```
79
+
80
+ Review and commit the wheel together with its source. The manual
81
+ `publish-cli.yml` workflow stages only the wheel matching the supplied SHA256.
82
+ It runs only from `main`; pushing code does not publish a package.
83
+
84
+ Before the first release from this repository, configure a PyPI trusted
85
+ publisher for project `mru-cli`, owner `Druzrka`, repository `mru-cli`, workflow
86
+ `publish-cli.yml`, environment `pypi`. Configure the GitHub `pypi` environment to
87
+ allow only `main`. The previous publisher for `mru-web` does not authorize this
88
+ repository. No PyPI release is performed as part of this extraction.
89
+
90
+ This repository was extracted from `Druzrka/mru-web` commit
91
+ `7bd1a908f8cb7ee993201462a452a7b4369c0897`. It contains only customer CLI code,
92
+ its prediction-only inference wrapper, tests, and release tooling.
@@ -0,0 +1,12 @@
1
+ mru_cli/__init__.py,sha256=YhaMTC9M8xkcHVrbI0-ixgfliHfe2tiDqXSOxf4uBMw,27
2
+ mru_cli/__main__.py,sha256=EwrK-u-Eg2JchXI165ldrOFBIaW6EO7Cx0ZMnwRRU6c,49
3
+ mru_cli/main.py,sha256=YiavBIGsiCckKs3Oopd23zjttYFF41GMUoODM5Vhjmk,21125
4
+ mru_cli/manifest.py,sha256=rxVqFNoope-VNFdztFg6fH2qogR9wuUc-ed2z6WjoiI,4125
5
+ mru_cli/modal_inference.py,sha256=6Rp013281Q0CSUaiZ0IGODOzI9oa_LhZGttGumtH4Bg,7046
6
+ mru_cli/project.py,sha256=4PL3W2Ivood6RdSV5U01xaOkLO8OhPhc75D69Tco3P4,4600
7
+ mru_cli/targets.py,sha256=CkA9ekhLxDeSAWXaP6AXzDet5i1gaIFLYVuXnZzrE20,4956
8
+ mru_cli-0.1.2.dist-info/METADATA,sha256=sLL_baGeCX_Fgb1fgX0hKP_DGobyD78LM-D6DUzWYd0,3777
9
+ mru_cli-0.1.2.dist-info/WHEEL,sha256=mOdXR9C3wMVov7cSY8PY-XCzo3SmmWCHvGQr5csJoao,91
10
+ mru_cli-0.1.2.dist-info/entry_points.txt,sha256=zsi5UooB8s2XG-5sGUblO9zy9xq-CNM1ED1P1f4vs8M,42
11
+ mru_cli-0.1.2.dist-info/top_level.txt,sha256=AScFE93QcEweyjCxy7S724GfkJSUPOnJ6nLvgbIeljg,8
12
+ mru_cli-0.1.2.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.2.1)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ mru = mru_cli.main:main
@@ -0,0 +1 @@
1
+ mru_cli