holocubic-cli-python 0.1.0__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.
@@ -0,0 +1,3 @@
1
+ """Python implementation of the HoloCubic CLI."""
2
+
3
+ __version__ = "0.1.0"
@@ -0,0 +1,3 @@
1
+ from .cli import main
2
+
3
+ raise SystemExit(main())
@@ -0,0 +1,85 @@
1
+ """Local HoloCubic app validation."""
2
+
3
+ import os
4
+ import re
5
+ from dataclasses import dataclass
6
+ from pathlib import Path, PurePosixPath
7
+
8
+ from .errors import CubicError, UsageError
9
+ from .remote_path import remote_join
10
+
11
+
12
+ @dataclass(frozen=True)
13
+ class ValidatedApp:
14
+ source: Path
15
+ id: str
16
+ destination: str
17
+ entry: str
18
+
19
+ def public(self) -> dict[str, str]:
20
+ return {
21
+ "source": str(self.source),
22
+ "id": self.id,
23
+ "destination": self.destination,
24
+ "entry": self.entry,
25
+ }
26
+
27
+
28
+ def validate_app_id(value: str) -> str:
29
+ app_id = value.strip()
30
+ if (
31
+ not app_id
32
+ or app_id in {".", ".."}
33
+ or app_id.startswith(".cubic-")
34
+ or "/" in app_id
35
+ or "\\" in app_id
36
+ or any(ord(character) < 32 for character in app_id)
37
+ ):
38
+ raise UsageError(f"Invalid app id: {value}")
39
+ remote_join("/sd/apps", app_id)
40
+ return app_id
41
+
42
+
43
+ def _require_file(file_path: Path, label: str) -> None:
44
+ try:
45
+ if file_path.is_symlink() or not file_path.is_file():
46
+ raise OSError("not a regular file")
47
+ except OSError as error:
48
+ raise CubicError(
49
+ f"App {label} is missing or is not a regular file: {file_path}",
50
+ code="INVALID_APP",
51
+ ) from error
52
+
53
+
54
+ def validate_app_directory(
55
+ directory: str | Path, requested_id: str | None = None
56
+ ) -> ValidatedApp:
57
+ source = Path(os.path.abspath(os.fspath(directory)))
58
+ if source.is_symlink() or not source.is_dir():
59
+ raise CubicError(
60
+ f"App source is not a regular directory: {source}", code="INVALID_APP"
61
+ )
62
+ info_path = source / "app.info"
63
+ _require_file(info_path, "metadata")
64
+ try:
65
+ info = info_path.read_text(encoding="utf-8")
66
+ except (OSError, UnicodeDecodeError) as error:
67
+ raise CubicError(
68
+ f"Unable to read app metadata: {info_path}", code="INVALID_APP"
69
+ ) from error
70
+ match = re.search(r"^\s*entry\s*=\s*(.+?)\s*$", info, flags=re.MULTILINE)
71
+ entry = match.group(1).strip() if match else "main.lua"
72
+ pure_entry = PurePosixPath(entry)
73
+ if (
74
+ not entry
75
+ or pure_entry.is_absolute()
76
+ or ".." in pure_entry.parts
77
+ or "\\" in entry
78
+ ):
79
+ raise CubicError(
80
+ f"app.info declares an unsafe entry: {entry}", code="INVALID_APP"
81
+ )
82
+ _require_file(source.joinpath(*pure_entry.parts), "entry")
83
+ _require_file(source / "main.lua", "main.lua")
84
+ app_id = validate_app_id(requested_id if requested_id is not None else source.name)
85
+ return ValidatedApp(source, app_id, remote_join("/sd/apps", app_id), entry)
@@ -0,0 +1,582 @@
1
+ """Command-line interface matching the Node reference implementation."""
2
+
3
+ import argparse
4
+ import json
5
+ import sys
6
+ import time
7
+ from collections.abc import Sequence
8
+ from pathlib import Path
9
+ from typing import Any
10
+
11
+ from . import __version__
12
+ from .app import validate_app_directory, validate_app_id
13
+ from .client import CubicClient, public_info
14
+ from .config import (
15
+ ConfigStore,
16
+ default_config_path,
17
+ resolve_device,
18
+ validate_device_name,
19
+ )
20
+ from .errors import CubicError, UsageError
21
+ from .models import TransferLimits
22
+ from .remote_path import assert_can_delete_remote, normalize_remote_path, remote_join
23
+ from .transfer import (
24
+ TransferProgress,
25
+ download_path,
26
+ ensure_remote_directory,
27
+ upload_path,
28
+ )
29
+ from .url import normalize_device_url
30
+
31
+
32
+ class _HelpAfterErrorParser(argparse.ArgumentParser):
33
+ """Keep argparse semantics while showing the relevant command help on errors."""
34
+
35
+ _missing_command: str | None = None
36
+
37
+ def show_help_when_command_is_missing(self, destination: str) -> None:
38
+ self._missing_command = destination
39
+
40
+ def error(self, message: str) -> None:
41
+ if message == f"the following arguments are required: {self._missing_command}":
42
+ self.print_help(sys.stderr)
43
+ self.exit(2)
44
+ print(f"{self.prog}: error: {message}", file=sys.stderr)
45
+ print(file=sys.stderr)
46
+ self.print_help(sys.stderr)
47
+ self.exit(2)
48
+
49
+
50
+ def _add_command(
51
+ subparsers: Any, name: str, description: str, **kwargs: Any
52
+ ) -> argparse.ArgumentParser:
53
+ return subparsers.add_parser(
54
+ name, help=description, description=description, **kwargs
55
+ )
56
+
57
+
58
+ def _positive_integer(value: str) -> int:
59
+ try:
60
+ parsed = int(value)
61
+ except ValueError as error:
62
+ raise argparse.ArgumentTypeError("must be a positive integer") from error
63
+ if parsed <= 0:
64
+ raise argparse.ArgumentTypeError("must be a positive integer")
65
+ return parsed
66
+
67
+
68
+ def _add_transfer_options(
69
+ parser: argparse.ArgumentParser, *, download: bool = False
70
+ ) -> None:
71
+ parser.add_argument(
72
+ "-f", "--force", action="store_true", help="replace an existing target"
73
+ )
74
+ parser.add_argument(
75
+ "--retries",
76
+ type=_positive_integer,
77
+ default=2,
78
+ help="retry transient chunk failures",
79
+ )
80
+ parser.add_argument(
81
+ "--max-depth", type=_positive_integer, default=32, help="recursive depth limit"
82
+ )
83
+ parser.add_argument(
84
+ "--max-entries",
85
+ type=_positive_integer,
86
+ default=4096,
87
+ help="recursive entry limit",
88
+ )
89
+ if download:
90
+ parser.add_argument(
91
+ "--max-bytes",
92
+ type=_positive_integer,
93
+ default=128 * 1024 * 1024,
94
+ help="aggregate directory download limit",
95
+ )
96
+
97
+
98
+ def build_parser() -> argparse.ArgumentParser:
99
+ parser = _HelpAfterErrorParser(
100
+ prog="cubic-py",
101
+ description="Manage HoloCubic DevTools devices and SD-card files",
102
+ )
103
+ parser.add_argument(
104
+ "--version", action="version", version=f"%(prog)s {__version__}"
105
+ )
106
+ parser.add_argument(
107
+ "-H", "--host", help="use a device without changing saved configuration"
108
+ )
109
+ parser.add_argument(
110
+ "--timeout", type=_positive_integer, default=60_000, metavar="MILLISECONDS"
111
+ )
112
+ parser.add_argument(
113
+ "--json",
114
+ action="store_true",
115
+ dest="as_json",
116
+ help="write stable JSON to stdout",
117
+ )
118
+ parser.add_argument(
119
+ "--quiet", action="store_true", help="suppress progress and success messages"
120
+ )
121
+ parser.add_argument("--config", help="override the configuration file")
122
+ commands = parser.add_subparsers(dest="command", required=True)
123
+ parser.show_help_when_command_is_missing("command")
124
+
125
+ device = _add_command(commands, "device", "manage saved devices")
126
+ device_commands = device.add_subparsers(dest="device_command", required=True)
127
+ device.show_help_when_command_is_missing("device_command")
128
+ device_add = _add_command(device_commands, "add", "verify and save a device")
129
+ device_add.add_argument("name")
130
+ device_add.add_argument("device_host")
131
+ device_add.add_argument("--no-use", action="store_false", dest="use", default=True)
132
+ _add_command(device_commands, "list", "list saved devices")
133
+ device_use = _add_command(device_commands, "use", "select a saved device")
134
+ device_use.add_argument("name")
135
+ device_remove = _add_command(device_commands, "remove", "remove a saved device")
136
+ device_remove.add_argument("name")
137
+
138
+ _add_command(commands, "ping", "test the selected device")
139
+ _add_command(commands, "info", "show device capabilities and transfer limits")
140
+ list_parser = _add_command(commands, "ls", "list a remote directory")
141
+ list_parser.add_argument("remote", nargs="?")
142
+ stat_parser = _add_command(
143
+ commands, "stat", "show remote file or directory metadata"
144
+ )
145
+ stat_parser.add_argument("remote")
146
+ cat_parser = _add_command(commands, "cat", "write a remote file to stdout")
147
+ cat_parser.add_argument("remote")
148
+ mkdir_parser = _add_command(
149
+ commands, "mkdir", "create a remote directory and missing parents"
150
+ )
151
+ mkdir_parser.add_argument("remote")
152
+ move_parser = _add_command(commands, "mv", "rename or move a remote path")
153
+ move_parser.add_argument("source")
154
+ move_parser.add_argument("target")
155
+ remove_parser = _add_command(commands, "rm", "remove a remote file or directory")
156
+ remove_parser.add_argument("remote")
157
+ remove_parser.add_argument("-r", "--recursive", action="store_true")
158
+ remove_parser.add_argument("-y", "--yes", action="store_true")
159
+
160
+ push_parser = _add_command(
161
+ commands,
162
+ "push",
163
+ "upload a file or directory recursively",
164
+ aliases=["upload"],
165
+ )
166
+ push_parser.add_argument("local")
167
+ push_parser.add_argument("remote", nargs="?")
168
+ _add_transfer_options(push_parser)
169
+ pull_parser = _add_command(
170
+ commands,
171
+ "pull",
172
+ "download a file or directory recursively",
173
+ aliases=["download"],
174
+ )
175
+ pull_parser.add_argument("remote")
176
+ pull_parser.add_argument("local", nargs="?")
177
+ _add_transfer_options(pull_parser, download=True)
178
+
179
+ devrun = _add_command(commands, "devrun", "read, save, or run DevRun source")
180
+ devrun_commands = devrun.add_subparsers(dest="devrun_command", required=True)
181
+ devrun.show_help_when_command_is_missing("devrun_command")
182
+ devrun_read = _add_command(devrun_commands, "read", "read DevRun source")
183
+ devrun_read.add_argument("output", nargs="?")
184
+ devrun_read.add_argument("-f", "--force", action="store_true")
185
+ for name in ("save", "run"):
186
+ description = f"{'save and run' if name == 'run' else 'save'} DevRun source"
187
+ child = _add_command(
188
+ devrun_commands,
189
+ name,
190
+ description,
191
+ )
192
+ child.add_argument("file")
193
+
194
+ app = _add_command(commands, "app", "list and install SD-card apps")
195
+ app_commands = app.add_subparsers(dest="app_command", required=True)
196
+ app.show_help_when_command_is_missing("app_command")
197
+ _add_command(app_commands, "list", "list installed apps")
198
+ app_install = _add_command(
199
+ app_commands, "install", "validate and upload an app directory"
200
+ )
201
+ app_install.add_argument("directory")
202
+ app_install.add_argument("--id")
203
+ app_install.add_argument("-f", "--force", action="store_true")
204
+ app_remove = _add_command(
205
+ app_commands, "remove", "remove an installed app directory"
206
+ )
207
+ app_remove.add_argument("id")
208
+ app_remove.add_argument("-y", "--yes", action="store_true", required=True)
209
+ return parser
210
+
211
+
212
+ def _configure_stdio() -> None:
213
+ for stream in (sys.stdout, sys.stderr):
214
+ reconfigure = getattr(stream, "reconfigure", None)
215
+ if callable(reconfigure):
216
+ reconfigure(encoding="utf-8", errors="backslashreplace")
217
+
218
+
219
+ def _format_bytes(value: int) -> str:
220
+ if value < 1024:
221
+ return f"{value} B"
222
+ if value < 1024**2:
223
+ return f"{value / 1024:.1f} KiB"
224
+ return f"{value / 1024**2:.1f} MiB"
225
+
226
+
227
+ class _Runtime:
228
+ def __init__(self, args: argparse.Namespace) -> None:
229
+ self.args = args
230
+ config_path = (
231
+ Path(args.config).resolve() if args.config else default_config_path()
232
+ )
233
+ self.store = ConfigStore(config_path)
234
+
235
+ def target(self) -> tuple[CubicClient, str | None, str]:
236
+ resolved = resolve_device(self.store, self.args.host)
237
+ url = str(resolved["url"])
238
+ return CubicClient(url, self.args.timeout), resolved["name"], url
239
+
240
+ def output(self, value: Any, human: Sequence[str] | str | None = None) -> None:
241
+ if self.args.as_json:
242
+ print(json.dumps(value, ensure_ascii=False, separators=(",", ":")))
243
+ elif not self.args.quiet and human is not None:
244
+ if isinstance(human, str):
245
+ print(human)
246
+ else:
247
+ for line in human:
248
+ print(line)
249
+
250
+ def progress(self, event: TransferProgress) -> None:
251
+ if self.args.as_json or self.args.quiet:
252
+ return
253
+ if event.phase == "scan":
254
+ print(f"Scanning {event.path} ...", file=sys.stderr)
255
+ return
256
+ total = f" / {_format_bytes(event.total_bytes)}" if event.total_bytes else ""
257
+ line = f"{event.phase:<8} {_format_bytes(event.transferred_bytes)}{total} {event.path}"
258
+ if sys.stderr.isatty():
259
+ print(
260
+ f"\r{line:<90}",
261
+ end="\n" if event.phase == "commit" else "",
262
+ file=sys.stderr,
263
+ )
264
+ elif event.phase == "commit" or event.transferred_bytes == event.total_bytes:
265
+ print(line, file=sys.stderr)
266
+
267
+ def execute(self) -> None:
268
+ args = self.args
269
+ if args.command == "device":
270
+ self._device()
271
+ elif args.command == "ping":
272
+ client, name, url = self.target()
273
+ started = time.perf_counter()
274
+ info = client.info(force=True)
275
+ latency = round((time.perf_counter() - started) * 1000)
276
+ self.output(
277
+ {
278
+ "ok": True,
279
+ "name": name,
280
+ "url": url,
281
+ "latency_ms": latency,
282
+ "version": info.version,
283
+ },
284
+ f"Connected to {name or url} in {latency} ms{f' ({info.version})' if info.version else ''}.",
285
+ )
286
+ elif args.command == "info":
287
+ client, name, url = self.target()
288
+ info = client.info(force=True)
289
+ self.output(
290
+ public_info(info, url, name),
291
+ [
292
+ f"Device: {name or '(temporary)'}",
293
+ f"URL: {url}",
294
+ f"Version: {info.version or 'unknown'}",
295
+ f"API: v{info.api_version}",
296
+ f"Root: {info.root_path}",
297
+ f"Chunk size: {_format_bytes(info.chunk_size)}",
298
+ f"Max file: {_format_bytes(info.max_file_size)}",
299
+ f"Capabilities: {', '.join(info.capabilities)}",
300
+ ],
301
+ )
302
+ elif args.command == "ls":
303
+ client, _, _ = self.target()
304
+ result = client.list(normalize_remote_path(args.remote))
305
+ self.output(
306
+ result.public(),
307
+ [
308
+ f"{'d' if item.is_dir else '-'} {'' if item.is_dir else str(item.size):>10} {item.name}{'/' if item.is_dir else ''}"
309
+ for item in result.items
310
+ ],
311
+ )
312
+ elif args.command == "stat":
313
+ client, _, _ = self.target()
314
+ result = client.stat(args.remote)
315
+ self.output(
316
+ result.public(),
317
+ [
318
+ f"Path: {result.path}",
319
+ f"Type: {'directory' if result.is_dir else 'file'}",
320
+ f"Size: {result.size} bytes",
321
+ f"MIME: {result.mime}",
322
+ ],
323
+ )
324
+ elif args.command == "cat":
325
+ if args.as_json:
326
+ raise UsageError("`cat` cannot be combined with --json.")
327
+ client, _, _ = self.target()
328
+ remote = normalize_remote_path(args.remote)
329
+ item = client.stat(remote)
330
+ if item.is_dir:
331
+ raise CubicError(
332
+ f"Remote source is a directory: {remote}", code="NOT_A_FILE"
333
+ )
334
+ info = client.info()
335
+ offset = 0
336
+ writer = getattr(sys.stdout, "buffer", sys.stdout)
337
+ while offset < item.size:
338
+ chunk = client.read(
339
+ remote, offset, min(info.chunk_size, item.size - offset)
340
+ )
341
+ if chunk.next_offset != offset + len(chunk.data) or not chunk.data:
342
+ raise CubicError(
343
+ f"Device returned an invalid read offset for {remote}.",
344
+ code="INVALID_RESPONSE",
345
+ )
346
+ writer.write(chunk.data)
347
+ offset = chunk.next_offset
348
+ elif args.command == "mkdir":
349
+ client, _, _ = self.target()
350
+ target = normalize_remote_path(args.remote)
351
+ ensure_remote_directory(client, target)
352
+ self.output({"path": target}, f"Created {target}")
353
+ elif args.command == "mv":
354
+ client, _, _ = self.target()
355
+ source = normalize_remote_path(args.source)
356
+ target = normalize_remote_path(args.target)
357
+ client.rename(source, target)
358
+ self.output(
359
+ {"source": source, "target": target}, f"Moved {source} -> {target}"
360
+ )
361
+ elif args.command == "rm":
362
+ client, _, _ = self.target()
363
+ target = normalize_remote_path(args.remote)
364
+ assert_can_delete_remote(target)
365
+ item = client.stat(target)
366
+ if item.is_dir:
367
+ if not args.recursive:
368
+ raise UsageError(
369
+ f"Remote path is a directory; use --recursive: {target}"
370
+ )
371
+ if not args.yes:
372
+ raise UsageError("Recursive deletion requires --yes.")
373
+ client.rmdir(target, True)
374
+ else:
375
+ client.remove(target)
376
+ self.output(
377
+ {"removed": target, "recursive": item.is_dir}, f"Removed {target}"
378
+ )
379
+ elif args.command in {"push", "upload"}:
380
+ client, _, _ = self.target()
381
+ result = upload_path(
382
+ client,
383
+ Path(args.local),
384
+ args.remote,
385
+ force=args.force,
386
+ retries=args.retries,
387
+ limits=TransferLimits(args.max_depth, args.max_entries),
388
+ on_progress=self.progress,
389
+ )
390
+ self.output(
391
+ result.public(),
392
+ f"Uploaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
393
+ )
394
+ elif args.command in {"pull", "download"}:
395
+ client, _, _ = self.target()
396
+ result = download_path(
397
+ client,
398
+ args.remote,
399
+ Path(args.local) if args.local else None,
400
+ force=args.force,
401
+ retries=args.retries,
402
+ limits=TransferLimits(args.max_depth, args.max_entries, args.max_bytes),
403
+ on_progress=self.progress,
404
+ )
405
+ self.output(
406
+ result.public(),
407
+ f"Downloaded {result.files} file(s), {_format_bytes(result.bytes)} -> {result.destination}",
408
+ )
409
+ elif args.command == "devrun":
410
+ self._devrun()
411
+ elif args.command == "app":
412
+ self._app()
413
+ else:
414
+ raise UsageError(f"Unknown command: {args.command}")
415
+
416
+ def _device(self) -> None:
417
+ args = self.args
418
+ if args.device_command == "add":
419
+ name = validate_device_name(args.name)
420
+ url = normalize_device_url(args.device_host)
421
+ info = CubicClient(url, args.timeout).info(force=True)
422
+ config = self.store.read()
423
+ profile = {"url": url}
424
+ if info.version:
425
+ profile["version"] = info.version
426
+ config["devices"][name] = profile
427
+ if args.use:
428
+ config["current"] = name
429
+ self.store.write(config)
430
+ self.output(
431
+ {
432
+ "name": name,
433
+ "url": url,
434
+ "selected": config["current"] == name,
435
+ "version": info.version,
436
+ },
437
+ [f"Added {name}: {url}"]
438
+ + ([f"Selected device: {name}"] if config["current"] == name else []),
439
+ )
440
+ elif args.device_command == "list":
441
+ config = self.store.read()
442
+ rows = [
443
+ {
444
+ "name": name,
445
+ "url": profile["url"],
446
+ "version": profile.get("version"),
447
+ "selected": name == config["current"],
448
+ }
449
+ for name, profile in sorted(config["devices"].items())
450
+ ]
451
+ lines: list[str] = []
452
+ for row in rows:
453
+ suffix = f" {row['version']}" if row["version"] else ""
454
+ lines.append(
455
+ f"{'*' if row['selected'] else ' '} {row['name']:<16} {row['url']}{suffix}"
456
+ )
457
+ if not lines:
458
+ lines = ["No saved devices."]
459
+ self.output({"current": config["current"], "devices": rows}, lines)
460
+ elif args.device_command in {"use", "remove"}:
461
+ name = validate_device_name(args.name)
462
+ config = self.store.read()
463
+ if name not in config["devices"]:
464
+ raise CubicError(f"Unknown device: {name}", code="NO_DEVICE")
465
+ if args.device_command == "use":
466
+ config["current"] = name
467
+ self.store.write(config)
468
+ self.output({"current": name}, f"Selected device: {name}")
469
+ else:
470
+ del config["devices"][name]
471
+ if config["current"] == name:
472
+ config["current"] = None
473
+ self.store.write(config)
474
+ self.output(
475
+ {"removed": name, "current": config["current"]},
476
+ f"Removed device: {name}",
477
+ )
478
+
479
+ def _devrun(self) -> None:
480
+ args = self.args
481
+ client, _, _ = self.target()
482
+ if args.devrun_command == "read":
483
+ source = client.read_devrun()
484
+ if not args.output:
485
+ if args.as_json:
486
+ self.output({"source": source})
487
+ else:
488
+ print(source, end="")
489
+ return
490
+ output_path = Path(args.output).resolve()
491
+ mode = "w" if args.force else "x"
492
+ try:
493
+ with output_path.open(mode, encoding="utf-8", newline="") as handle:
494
+ handle.write(source)
495
+ except FileExistsError as error:
496
+ raise CubicError(
497
+ f"Local target already exists: {output_path}. Use --force to replace it.",
498
+ code="TARGET_EXISTS",
499
+ ) from error
500
+ self.output(
501
+ {"path": str(output_path), "bytes": len(source.encode("utf-8"))},
502
+ f"Saved DevRun source to {output_path}",
503
+ )
504
+ else:
505
+ source = Path(args.file).resolve().read_text(encoding="utf-8")
506
+ run = args.devrun_command == "run"
507
+ result = client.save_devrun(source, run)
508
+ self.output(
509
+ result.public(),
510
+ f"{'Ran' if run else 'Saved'} {result.entry} ({result.bytes} bytes)",
511
+ )
512
+
513
+ def _app(self) -> None:
514
+ args = self.args
515
+ client, _, _ = self.target()
516
+ if args.app_command == "list":
517
+ result = client.apps()
518
+ self.output(
519
+ result.public(),
520
+ [
521
+ f"{item.get('id', '')}{' *' if item.get('id') == result.current_app_id else ''}"
522
+ for item in result.apps
523
+ ],
524
+ )
525
+ elif args.app_command == "install":
526
+ validated = validate_app_directory(Path(args.directory), args.id)
527
+ apps = client.apps()
528
+ if validated.id == apps.run_app_id:
529
+ raise UsageError(
530
+ f"Refusing to replace {apps.run_app_id}; use the dedicated devrun commands."
531
+ )
532
+ if validated.id == apps.current_app_id:
533
+ raise UsageError(
534
+ f"Refusing to replace the currently running app {validated.id}; switch apps first."
535
+ )
536
+ transfer = upload_path(
537
+ client,
538
+ validated.source,
539
+ validated.destination,
540
+ force=args.force,
541
+ on_progress=self.progress,
542
+ )
543
+ result = {
544
+ **validated.public(),
545
+ "transfer": transfer.public(),
546
+ "rescanRequired": True,
547
+ }
548
+ self.output(
549
+ result,
550
+ [
551
+ f"Installed {validated.id} -> {validated.destination}",
552
+ "Rescan apps on the device before first launch.",
553
+ ],
554
+ )
555
+ elif args.app_command == "remove":
556
+ app_id = validate_app_id(args.id)
557
+ target = remote_join("/sd/apps", app_id)
558
+ apps = client.apps()
559
+ if app_id == apps.run_app_id:
560
+ raise UsageError(
561
+ f"Refusing to remove {apps.run_app_id}; it is managed by the dedicated devrun commands."
562
+ )
563
+ if app_id == apps.current_app_id:
564
+ raise UsageError(
565
+ f"Refusing to remove the currently running app {app_id}; switch apps first."
566
+ )
567
+ client.rmdir(target, True)
568
+ self.output({"removed": app_id, "path": target}, f"Removed app {app_id}")
569
+
570
+
571
+ def main(argv: Sequence[str] | None = None) -> int:
572
+ _configure_stdio()
573
+ args = build_parser().parse_args(argv)
574
+ try:
575
+ _Runtime(args).execute()
576
+ return 0
577
+ except CubicError as error:
578
+ print(f"cubic-py: {error}", file=sys.stderr)
579
+ return error.exit_code
580
+ except (OSError, UnicodeError) as error:
581
+ print(f"cubic-py: {error}", file=sys.stderr)
582
+ return 1