meshagent-cli 0.22.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.

Potentially problematic release.


This version of meshagent-cli might be problematic. Click here for more details.

Files changed (45) hide show
  1. meshagent/cli/__init__.py +3 -0
  2. meshagent/cli/agent.py +273 -0
  3. meshagent/cli/api_keys.py +102 -0
  4. meshagent/cli/async_typer.py +79 -0
  5. meshagent/cli/auth.py +30 -0
  6. meshagent/cli/auth_async.py +295 -0
  7. meshagent/cli/call.py +215 -0
  8. meshagent/cli/chatbot.py +1983 -0
  9. meshagent/cli/cli.py +187 -0
  10. meshagent/cli/cli_mcp.py +408 -0
  11. meshagent/cli/cli_secrets.py +414 -0
  12. meshagent/cli/common_options.py +47 -0
  13. meshagent/cli/containers.py +725 -0
  14. meshagent/cli/database.py +997 -0
  15. meshagent/cli/developer.py +70 -0
  16. meshagent/cli/exec.py +397 -0
  17. meshagent/cli/helper.py +236 -0
  18. meshagent/cli/helpers.py +185 -0
  19. meshagent/cli/host.py +41 -0
  20. meshagent/cli/mailbot.py +1295 -0
  21. meshagent/cli/mailboxes.py +223 -0
  22. meshagent/cli/meeting_transcriber.py +138 -0
  23. meshagent/cli/messaging.py +157 -0
  24. meshagent/cli/multi.py +357 -0
  25. meshagent/cli/oauth2.py +341 -0
  26. meshagent/cli/participant_token.py +63 -0
  27. meshagent/cli/port.py +70 -0
  28. meshagent/cli/projects.py +105 -0
  29. meshagent/cli/queue.py +91 -0
  30. meshagent/cli/room.py +26 -0
  31. meshagent/cli/rooms.py +214 -0
  32. meshagent/cli/services.py +722 -0
  33. meshagent/cli/sessions.py +26 -0
  34. meshagent/cli/storage.py +813 -0
  35. meshagent/cli/sync.py +434 -0
  36. meshagent/cli/task_runner.py +1317 -0
  37. meshagent/cli/version.py +1 -0
  38. meshagent/cli/voicebot.py +624 -0
  39. meshagent/cli/webhook.py +100 -0
  40. meshagent/cli/worker.py +1403 -0
  41. meshagent_cli-0.22.2.dist-info/METADATA +49 -0
  42. meshagent_cli-0.22.2.dist-info/RECORD +45 -0
  43. meshagent_cli-0.22.2.dist-info/WHEEL +5 -0
  44. meshagent_cli-0.22.2.dist-info/entry_points.txt +2 -0
  45. meshagent_cli-0.22.2.dist-info/top_level.txt +1 -0
@@ -0,0 +1,725 @@
1
+ # meshagent/cli/containers.py
2
+ from __future__ import annotations
3
+
4
+ import asyncio
5
+ import io
6
+ import os
7
+ import tarfile
8
+ import time
9
+ from pathlib import Path
10
+
11
+ import pathlib
12
+ import pathspec
13
+
14
+ import aiofiles
15
+ import aiofiles.ospath
16
+ import typer
17
+ from rich import print
18
+ from typing import Annotated, Optional, List, Dict
19
+
20
+ from meshagent.cli import async_typer
21
+ from meshagent.cli.common_options import ProjectIdOption, RoomOption
22
+ from meshagent.cli.helper import (
23
+ get_client,
24
+ resolve_project_id,
25
+ resolve_room,
26
+ )
27
+ from meshagent.api import (
28
+ RoomClient,
29
+ WebSocketClientProtocol,
30
+ )
31
+ from meshagent.api.helpers import meshagent_base_url, websocket_room_url
32
+ from meshagent.api.room_server_client import (
33
+ DockerSecret,
34
+ )
35
+
36
+ import sys
37
+
38
+ app = async_typer.AsyncTyper(help="Manage containers and images inside a room")
39
+
40
+ # -------------------------
41
+ # Helpers
42
+ # -------------------------
43
+
44
+
45
+ def _parse_keyvals(items: List[str]) -> Dict[str, str]:
46
+ """
47
+ Parse ["KEY=VAL", "FOO=BAR"] -> {"KEY":"VAL", "FOO":"BAR"}
48
+ """
49
+ out: Dict[str, str] = {}
50
+ for s in items or []:
51
+ if "=" not in s:
52
+ raise typer.BadParameter(f"Expected KEY=VALUE, got: {s}")
53
+ k, v = s.split("=", 1)
54
+ out[k] = v
55
+ return out
56
+
57
+
58
+ def _parse_ports(items: List[str]) -> Dict[int, int]:
59
+ """
60
+ Parse ["8080:3000", "9999:9999"] as CONTAINER:HOST -> {8080:3000, 9999:9999}
61
+ (Matches server's expectation: container_port -> host_port.)
62
+ """
63
+ out: Dict[int, int] = {}
64
+ for s in items or []:
65
+ if ":" not in s:
66
+ raise typer.BadParameter(f"Expected CONTAINER:HOST, got: {s}")
67
+ c, h = s.split(":", 1)
68
+ try:
69
+ cp, hp = int(c), int(h)
70
+ except ValueError:
71
+ raise typer.BadParameter(f"Ports must be integers, got: {s}")
72
+ out[cp] = hp
73
+ return out
74
+
75
+
76
+ def _parse_creds(items: List[str]) -> List[DockerSecret]:
77
+ """
78
+ Parse creds given as:
79
+ --cred username,password
80
+ --cred registry,username,password
81
+ """
82
+ creds: List[DockerSecret] = []
83
+ for s in items or []:
84
+ parts = [p.strip() for p in s.split(",")]
85
+ if len(parts) == 2:
86
+ u, p = parts
87
+ creds.append(DockerSecret(username=u, password=p))
88
+ elif len(parts) == 3:
89
+ r, u, p = parts
90
+ creds.append(DockerSecret(registry=r, username=u, password=p))
91
+ else:
92
+ raise typer.BadParameter(
93
+ f"Invalid --cred format: {s}. Use username,password or registry,username,password"
94
+ )
95
+ return creds
96
+
97
+
98
+ class DockerIgnore:
99
+ def __init__(self, dockerignore_path: str):
100
+ """
101
+ Load a .dockerignore file and compile its patterns.
102
+ """
103
+ dockerignore_file = pathlib.Path(dockerignore_path)
104
+ if dockerignore_file.exists():
105
+ with dockerignore_file.open("r") as f:
106
+ patterns = f.read().splitlines()
107
+ else:
108
+ patterns = []
109
+
110
+ # pathspec with gitwildmatch is the same style used by dockerignore
111
+ self._spec = pathspec.PathSpec.from_lines("gitwildmatch", patterns)
112
+
113
+ def matches(self, path: str) -> bool:
114
+ """
115
+ Return True if the given path matches a pattern in the .dockerignore file.
116
+ Path can be relative or absolute.
117
+ """
118
+ return self._spec.match_file(path)
119
+
120
+
121
+ async def _make_targz_from_dir(path: Path) -> bytes:
122
+ buf = io.BytesIO()
123
+
124
+ docker_ignore = None
125
+
126
+ def _tarfilter_strip_macos(ti: tarfile.TarInfo) -> tarfile.TarInfo | None:
127
+ """
128
+ Filter to make Linux-friendly tarballs:
129
+ - Drop AppleDouble files (._*)
130
+ - Reset uid/gid/uname/gname
131
+ - Clear pax headers
132
+ """
133
+
134
+ if docker_ignore is not None:
135
+ if docker_ignore.matches(ti.path):
136
+ return None
137
+
138
+ base = os.path.basename(ti.name)
139
+ if base.startswith("._"):
140
+ return None
141
+ ti.uid = 0
142
+ ti.gid = 0
143
+ ti.uname = ""
144
+ ti.gname = ""
145
+ ti.pax_headers = {}
146
+ # Preserve mode & type; set a stable-ish mtime
147
+ if ti.mtime is None:
148
+ ti.mtime = int(time.time())
149
+ return ti
150
+
151
+ docker_ignore_path = path.joinpath(".dockerignore")
152
+
153
+ if await aiofiles.ospath.exists(docker_ignore_path):
154
+ docker_ignore = DockerIgnore(docker_ignore_path)
155
+
156
+ with tarfile.open(fileobj=buf, mode="w:gz") as tar:
157
+ tar.add(path, arcname=".", filter=_tarfilter_strip_macos)
158
+ return buf.getvalue()
159
+
160
+
161
+ async def _drain_stream_plain(stream, *, show_progress: bool = True):
162
+ async def _logs():
163
+ async for line in stream.logs():
164
+ # Server emits plain lines; print as-is
165
+ if line is not None:
166
+ print(line)
167
+
168
+ async def _prog():
169
+ if not show_progress:
170
+ async for _ in stream.progress():
171
+ pass
172
+ return
173
+ async for p in stream.progress():
174
+ if p is None:
175
+ return
176
+ msg = p.message or ""
177
+ # Show progress if we have numbers, else just the message.
178
+ if p.current is not None and p.total:
179
+ print(f"[cyan]{msg} ({p.current}/{p.total})[/cyan]")
180
+ elif msg:
181
+ print(f"[cyan]{msg}[/cyan]")
182
+
183
+ t1 = asyncio.create_task(_logs())
184
+ t2 = asyncio.create_task(_prog())
185
+ try:
186
+ return await stream
187
+ finally:
188
+ await asyncio.gather(t1, t2, return_exceptions=True)
189
+
190
+
191
+ async def _drain_stream_pretty(stream):
192
+ import asyncio
193
+ import math
194
+ from rich.table import Column
195
+ from rich.live import Live
196
+ from rich.panel import Panel
197
+ from rich.console import Group
198
+ from rich.text import Text
199
+ from rich.progress import (
200
+ Progress,
201
+ TextColumn,
202
+ BarColumn,
203
+ TimeElapsedColumn,
204
+ ProgressColumn,
205
+ SpinnerColumn,
206
+ )
207
+
208
+ class MaybeMofN(ProgressColumn):
209
+ def render(self, task):
210
+ import math
211
+ from rich.text import Text
212
+
213
+ def _fmt_bytes(n):
214
+ if n is None:
215
+ return ""
216
+ n = float(n)
217
+ units = ["B", "KiB", "MiB", "GiB", "TiB"]
218
+ i = 0
219
+ while n >= 1024 and i < len(units) - 1:
220
+ n /= 1024
221
+ i += 1
222
+ return f"{n:.1f} {units[i]}"
223
+
224
+ if task.total == 0 or math.isinf(task.total):
225
+ return Text("")
226
+ return Text(f"{_fmt_bytes(task.completed)} / {_fmt_bytes(task.total)}")
227
+
228
+ class MaybeBarColumn(BarColumn):
229
+ def __init__(
230
+ self,
231
+ *,
232
+ bar_width: int | None = 28,
233
+ hide_when_unknown: bool = False,
234
+ column_width: int | None = None,
235
+ **kwargs,
236
+ ):
237
+ # bar_width controls the drawn bar size; None = flex
238
+ super().__init__(bar_width=bar_width, **kwargs)
239
+ self.hide_when_unknown = hide_when_unknown
240
+ self.column_width = column_width # fix the table column if set
241
+
242
+ def get_table_column(self) -> Column:
243
+ if self.column_width is None:
244
+ # default behavior (may flex depending on layout)
245
+ return Column(no_wrap=True)
246
+ return Column(
247
+ width=self.column_width,
248
+ min_width=self.column_width,
249
+ max_width=self.column_width,
250
+ no_wrap=True,
251
+ overflow="crop",
252
+ justify="left",
253
+ )
254
+
255
+ def render(self, task):
256
+ if task.total is None or task.total == 0 or math.isinf(task.total):
257
+ return Text("") # hide bar for indeterminate tasks
258
+ return super().render(task)
259
+
260
+ class MaybeETA(ProgressColumn):
261
+ """Show ETA only if total is known."""
262
+
263
+ _elapsed = TimeElapsedColumn()
264
+
265
+ def render(self, task):
266
+ # You can swap this to a TimeRemainingColumn() if you prefer,
267
+ # but hide when total is unknown.
268
+ if task.total == 0 or math.isinf(task.total):
269
+ return Text("")
270
+ return self._elapsed.render(task) # or TimeRemainingColumn().render(task)
271
+
272
+ progress = Progress(
273
+ SpinnerColumn(),
274
+ TextColumn(
275
+ "[bold]{task.description}",
276
+ table_column=Column(ratio=8, no_wrap=True, overflow="ellipsis"),
277
+ ),
278
+ MaybeMofN(table_column=Column(ratio=2, no_wrap=True, overflow="ellipsis")),
279
+ MaybeETA(table_column=Column(ratio=1, no_wrap=True, overflow="ellipsis")),
280
+ MaybeBarColumn(pulse_style="cyan", bar_width=20, hide_when_unknown=True),
281
+ # pulses automatically if total=None
282
+ transient=False, # we’re inside Live; we’ll hide tasks ourselves
283
+ expand=True,
284
+ )
285
+
286
+ logs_tail: list[str] = []
287
+ tasks: dict[str, int] = {} # layer -> task_id
288
+
289
+ def render():
290
+ tail = "\n".join(logs_tail[-12:]) or "waiting…"
291
+ return Group(
292
+ progress,
293
+ Panel(tail, title="logs", border_style="cyan", height=12),
294
+ )
295
+
296
+ async def _logs():
297
+ async for line in stream.logs():
298
+ if line:
299
+ logs_tail.append(line.strip())
300
+
301
+ async def _prog():
302
+ async for p in stream.progress():
303
+ layer = p.layer or "overall"
304
+ if layer not in tasks:
305
+ tasks[layer] = progress.add_task(
306
+ p.message or layer, total=p.total if p.total is not None else 0
307
+ )
308
+ task_id = tasks[layer]
309
+
310
+ updates = {}
311
+ # Keep total=None for pulsing; only set if we get a real number.
312
+ if p.total is not None and not math.isinf(p.total):
313
+ updates["total"] = p.total
314
+ if p.current is not None:
315
+ updates["completed"] = p.current
316
+ if p.message:
317
+ updates["description"] = p.message
318
+ if updates:
319
+ progress.update(task_id, **updates)
320
+
321
+ with Live(render(), refresh_per_second=10) as live:
322
+
323
+ async def _refresh():
324
+ while True:
325
+ live.update(render())
326
+ await asyncio.sleep(0.1)
327
+
328
+ t_logs = asyncio.create_task(_logs())
329
+ t_prog = asyncio.create_task(_prog())
330
+ t_ui = asyncio.create_task(_refresh())
331
+ try:
332
+ result = await stream
333
+ return result
334
+ finally:
335
+ # Hide any still-visible tasks (e.g., indeterminate ones with total=None)
336
+ for tid in list(tasks.values()):
337
+ progress.update(tid, visible=False)
338
+ live.update(render())
339
+
340
+ for t in (t_logs, t_prog):
341
+ await t
342
+
343
+ t_ui.cancel()
344
+
345
+
346
+ async def _with_client(
347
+ *,
348
+ project_id: ProjectIdOption,
349
+ room: RoomOption,
350
+ ):
351
+ account_client = await get_client()
352
+ try:
353
+ project_id = await resolve_project_id(project_id=project_id)
354
+ room = resolve_room(room)
355
+
356
+ connection = await account_client.connect_room(project_id=project_id, room=room)
357
+
358
+ proto = WebSocketClientProtocol(
359
+ url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
360
+ token=connection.jwt,
361
+ )
362
+ client_cm = RoomClient(protocol=proto)
363
+ await client_cm.__aenter__()
364
+ return account_client, client_cm
365
+ except Exception:
366
+ await account_client.close()
367
+ raise
368
+
369
+
370
+ # -------------------------
371
+ # Top-level: ps / stop / logs / run
372
+ # -------------------------
373
+
374
+
375
+ @app.async_command("ps")
376
+ async def list_containers(
377
+ *,
378
+ project_id: ProjectIdOption,
379
+ room: RoomOption,
380
+ output: Annotated[Optional[str], typer.Option(help="json | table")] = "json",
381
+ ):
382
+ account_client, client = await _with_client(
383
+ project_id=project_id,
384
+ room=room,
385
+ )
386
+ try:
387
+ containers = await client.containers.list()
388
+ if output == "table":
389
+ from rich.table import Table
390
+ from rich.console import Console
391
+
392
+ table = Table(title="Containers")
393
+ table.add_column("ID", style="cyan")
394
+ table.add_column("Image")
395
+ table.add_column("Status")
396
+ table.add_column("Name")
397
+ for c in containers:
398
+ table.add_row(c.id, c.image or "", c.status or "", c.name or "")
399
+ Console().print(table)
400
+ else:
401
+ # default json-ish
402
+ print([c.model_dump() for c in containers])
403
+ finally:
404
+ await client.__aexit__(None, None, None)
405
+ await account_client.close()
406
+
407
+
408
+ @app.async_command("stop")
409
+ async def stop_container(
410
+ *,
411
+ project_id: ProjectIdOption,
412
+ room: RoomOption,
413
+ id: Annotated[str, typer.Option(..., help="Container ID")],
414
+ ):
415
+ account_client, client = await _with_client(
416
+ project_id=project_id,
417
+ room=room,
418
+ )
419
+ try:
420
+ await client.containers.stop(container_id=id)
421
+ print("[green]Stopped[/green]")
422
+ finally:
423
+ await client.__aexit__(None, None, None)
424
+ await account_client.close()
425
+
426
+
427
+ @app.async_command("logs")
428
+ async def container_logs(
429
+ *,
430
+ project_id: ProjectIdOption,
431
+ room: RoomOption,
432
+ id: Annotated[str, typer.Option(..., help="Container ID")],
433
+ follow: Annotated[
434
+ bool, typer.Option("--follow/--no-follow", help="Stream logs")
435
+ ] = False,
436
+ ):
437
+ account_client, client = await _with_client(
438
+ project_id=project_id,
439
+ room=room,
440
+ )
441
+ try:
442
+ stream = client.containers.logs(container_id=id, follow=follow)
443
+ await _drain_stream_plain(stream)
444
+ finally:
445
+ await client.__aexit__(None, None, None)
446
+ await account_client.close()
447
+
448
+
449
+ @app.async_command("exec")
450
+ async def exec_container(
451
+ *,
452
+ project_id: ProjectIdOption,
453
+ room: RoomOption,
454
+ container_id: Annotated[str, typer.Option(..., help="container id")],
455
+ command: Annotated[
456
+ Optional[str],
457
+ typer.Option(..., help="Command to execute in the container (quoted string)"),
458
+ ] = None,
459
+ tty: Annotated[bool, typer.Option(..., help="Allocate a TTY")] = False,
460
+ ):
461
+ account_client, client = await _with_client(
462
+ project_id=project_id,
463
+ room=room,
464
+ )
465
+ result = 1
466
+
467
+ try:
468
+ import termios
469
+
470
+ from contextlib import contextmanager
471
+
472
+ container = await client.containers.exec(
473
+ container_id=container_id,
474
+ command=command,
475
+ tty=tty,
476
+ )
477
+
478
+ async def write_all(fd, data: bytes) -> None:
479
+ loop = asyncio.get_running_loop()
480
+ mv = memoryview(data)
481
+
482
+ while mv:
483
+ try:
484
+ n = os.write(fd, mv)
485
+ mv = mv[n:]
486
+ except BlockingIOError:
487
+ fut = loop.create_future()
488
+ loop.add_writer(fd, fut.set_result, None)
489
+ try:
490
+ await fut
491
+ finally:
492
+ loop.remove_writer(fd)
493
+
494
+ async def read_stderr():
495
+ async for output in container.stderr():
496
+ await write_all(sys.stderr.fileno(), output)
497
+
498
+ async def read_stdout():
499
+ async for output in container.stdout():
500
+ await write_all(sys.stdout.fileno(), output)
501
+
502
+ @contextmanager
503
+ def raw_mode(fd: int):
504
+ import tty
505
+
506
+ old = termios.tcgetattr(fd)
507
+ try:
508
+ tty.setraw(fd) # immediate bytes
509
+ yield
510
+ finally:
511
+ termios.tcsetattr(fd, termios.TCSADRAIN, old)
512
+
513
+ async def read_piped_stdin(bufsize: int = 1024):
514
+ while True:
515
+ chunk = await asyncio.to_thread(sys.stdin.buffer.read, bufsize)
516
+
517
+ if not chunk or len(chunk) == 0:
518
+ await container.close_stdin()
519
+ break
520
+
521
+ await container.write(chunk)
522
+
523
+ async def read_stdin(bufsize: int = 1024):
524
+ # If stdin is piped, just read normally (blocking is fine; no TTY semantics)
525
+ if not sys.stdin.isatty():
526
+ while True:
527
+ chunk = sys.stdin.buffer.read(bufsize)
528
+ if not chunk:
529
+ return
530
+ await container.write(chunk)
531
+ return
532
+
533
+ fd = sys.stdin.fileno()
534
+
535
+ # Make reads non-blocking so we never hang shutdown
536
+ prev_blocking = os.get_blocking(fd)
537
+ os.set_blocking(fd, False)
538
+
539
+ try:
540
+ with raw_mode(fd):
541
+ while True:
542
+ try:
543
+ chunk = os.read(fd, bufsize)
544
+ except BlockingIOError:
545
+ # nothing typed yet
546
+ await asyncio.sleep(0.01)
547
+ continue
548
+
549
+ if chunk == b"":
550
+ return
551
+
552
+ # optional: allow Ctrl-C to exit
553
+ if chunk == b"\x03":
554
+ return
555
+
556
+ await container.write(chunk)
557
+ finally:
558
+ os.set_blocking(fd, prev_blocking)
559
+
560
+ if not tty and not sys.stdin.isatty():
561
+ await asyncio.gather(read_stdout(), read_stderr(), read_piped_stdin())
562
+ else:
563
+ if not sys.stdin.isatty():
564
+ print("[red]TTY requested but not a TTY[/red]")
565
+ raise typer.Exit(-1)
566
+
567
+ reader = asyncio.create_task(read_stdin())
568
+ await asyncio.gather(read_stdout(), read_stderr())
569
+ reader.cancel()
570
+
571
+ result = await container.result
572
+ finally:
573
+ await client.__aexit__(None, None, None)
574
+ await account_client.close()
575
+
576
+ sys.exit(result)
577
+
578
+
579
+ # -------------------------
580
+ # Run (detached)
581
+ # -------------------------
582
+
583
+
584
+ @app.async_command("run")
585
+ async def run_container(
586
+ *,
587
+ project_id: ProjectIdOption,
588
+ room: RoomOption,
589
+ image: Annotated[str, typer.Option(..., help="Image to run")],
590
+ command: Annotated[
591
+ Optional[str],
592
+ typer.Option(..., help="Command to execute in the container (quoted string)"),
593
+ ] = None,
594
+ env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
595
+ port: Annotated[
596
+ List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
597
+ ] = [],
598
+ var: Annotated[
599
+ List[str],
600
+ typer.Option("--var", help="Template variable KEY=VALUE (optional)"),
601
+ ] = [],
602
+ cred: Annotated[
603
+ List[str],
604
+ typer.Option(
605
+ "--cred",
606
+ help="Docker creds (username,password) or (registry,username,password)",
607
+ ),
608
+ ] = [],
609
+ mount_path: Annotated[
610
+ Optional[str],
611
+ typer.Option(help="Room storage path to mount into the container"),
612
+ ] = None,
613
+ mount_subpath: Annotated[
614
+ Optional[str],
615
+ typer.Option(help="Subpath within `--mount-path` to mount"),
616
+ ] = None,
617
+ participant_name: Annotated[
618
+ Optional[str], typer.Option(help="Participant name to associate with the run")
619
+ ] = None,
620
+ role: Annotated[
621
+ str, typer.Option(..., help="Role to run the container as")
622
+ ] = "user",
623
+ container_name: Annotated[
624
+ str, typer.Option(..., help="Optional container name")
625
+ ] = None,
626
+ ):
627
+ account_client, client = await _with_client(
628
+ project_id=project_id,
629
+ room=room,
630
+ )
631
+ try:
632
+ creds = _parse_creds(cred)
633
+ env_map = _parse_keyvals(env)
634
+ ports_map = _parse_ports(port)
635
+ vars_map = _parse_keyvals(var)
636
+
637
+ container_id = await client.containers.run(
638
+ name=container_name,
639
+ image=image,
640
+ command=command,
641
+ env=env_map,
642
+ mount_path=mount_path,
643
+ mount_subpath=mount_subpath,
644
+ role=role,
645
+ participant_name=participant_name,
646
+ ports=ports_map,
647
+ credentials=creds,
648
+ variables=vars_map or None,
649
+ )
650
+
651
+ print(f"Container started: {container_id}")
652
+ finally:
653
+ await client.__aexit__(None, None, None)
654
+ await account_client.close()
655
+
656
+
657
+ # -------------------------
658
+ # Images sub-commands
659
+ # -------------------------
660
+
661
+ images_app = async_typer.AsyncTyper(help="Image operations")
662
+ app.add_typer(images_app, name="images")
663
+
664
+
665
+ @images_app.async_command("list")
666
+ async def images_list(
667
+ *,
668
+ project_id: ProjectIdOption,
669
+ room: RoomOption,
670
+ ):
671
+ account_client, client = await _with_client(
672
+ project_id=project_id,
673
+ room=room,
674
+ )
675
+ try:
676
+ imgs = await client.containers.list_images()
677
+ print([i.model_dump() for i in imgs])
678
+ finally:
679
+ await client.__aexit__(None, None, None)
680
+ await account_client.close()
681
+
682
+
683
+ @images_app.async_command("delete")
684
+ async def images_delete(
685
+ *,
686
+ project_id: ProjectIdOption,
687
+ room: RoomOption,
688
+ image: Annotated[str, typer.Option(..., help="Image ref/tag to delete")],
689
+ ):
690
+ account_client, client = await _with_client(
691
+ project_id=project_id,
692
+ room=room,
693
+ )
694
+ try:
695
+ await client.containers.delete_image(image=image)
696
+ print("[green]Deleted[/green]")
697
+ finally:
698
+ await client.__aexit__(None, None, None)
699
+ await account_client.close()
700
+
701
+
702
+ @images_app.async_command("pull")
703
+ async def images_pull(
704
+ *,
705
+ project_id: ProjectIdOption,
706
+ room: RoomOption,
707
+ tag: Annotated[str, typer.Option(..., help="Image tag/ref to pull")],
708
+ cred: Annotated[
709
+ List[str],
710
+ typer.Option(
711
+ "--cred",
712
+ help="Docker creds (username,password) or (registry,username,password)",
713
+ ),
714
+ ] = [],
715
+ ):
716
+ account_client, client = await _with_client(
717
+ project_id=project_id,
718
+ room=room,
719
+ )
720
+ try:
721
+ await client.containers.pull_image(tag=tag, credentials=_parse_creds(cred))
722
+ print("Image pulled")
723
+ finally:
724
+ await client.__aexit__(None, None, None)
725
+ await account_client.close()