meshagent-cli 0.7.0__py3-none-any.whl → 0.21.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.
Files changed (41) hide show
  1. meshagent/cli/agent.py +15 -11
  2. meshagent/cli/api_keys.py +4 -4
  3. meshagent/cli/async_typer.py +52 -4
  4. meshagent/cli/call.py +12 -8
  5. meshagent/cli/chatbot.py +1007 -129
  6. meshagent/cli/cli.py +21 -20
  7. meshagent/cli/cli_mcp.py +92 -28
  8. meshagent/cli/cli_secrets.py +10 -10
  9. meshagent/cli/common_options.py +19 -4
  10. meshagent/cli/containers.py +164 -16
  11. meshagent/cli/database.py +997 -0
  12. meshagent/cli/developer.py +3 -3
  13. meshagent/cli/exec.py +22 -6
  14. meshagent/cli/helper.py +62 -11
  15. meshagent/cli/helpers.py +66 -9
  16. meshagent/cli/host.py +37 -0
  17. meshagent/cli/mailbot.py +1004 -40
  18. meshagent/cli/mailboxes.py +223 -0
  19. meshagent/cli/meeting_transcriber.py +10 -4
  20. meshagent/cli/messaging.py +7 -7
  21. meshagent/cli/multi.py +402 -0
  22. meshagent/cli/oauth2.py +44 -21
  23. meshagent/cli/participant_token.py +5 -3
  24. meshagent/cli/port.py +70 -0
  25. meshagent/cli/queue.py +2 -2
  26. meshagent/cli/room.py +20 -212
  27. meshagent/cli/rooms.py +214 -0
  28. meshagent/cli/services.py +32 -23
  29. meshagent/cli/sessions.py +5 -5
  30. meshagent/cli/storage.py +5 -5
  31. meshagent/cli/task_runner.py +770 -0
  32. meshagent/cli/version.py +1 -1
  33. meshagent/cli/voicebot.py +502 -76
  34. meshagent/cli/webhook.py +7 -7
  35. meshagent/cli/worker.py +1327 -0
  36. {meshagent_cli-0.7.0.dist-info → meshagent_cli-0.21.0.dist-info}/METADATA +13 -13
  37. meshagent_cli-0.21.0.dist-info/RECORD +44 -0
  38. meshagent_cli-0.7.0.dist-info/RECORD +0 -36
  39. {meshagent_cli-0.7.0.dist-info → meshagent_cli-0.21.0.dist-info}/WHEEL +0 -0
  40. {meshagent_cli-0.7.0.dist-info → meshagent_cli-0.21.0.dist-info}/entry_points.txt +0 -0
  41. {meshagent_cli-0.7.0.dist-info → meshagent_cli-0.21.0.dist-info}/top_level.txt +0 -0
meshagent/cli/cli.py CHANGED
@@ -3,35 +3,35 @@ import asyncio
3
3
 
4
4
  from meshagent.cli import async_typer
5
5
 
6
- from meshagent.cli import queue
6
+ from meshagent.cli import multi
7
+
7
8
  from meshagent.cli import auth
8
9
  from meshagent.cli import api_keys
9
10
  from meshagent.cli import projects
10
11
  from meshagent.cli import sessions
11
12
  from meshagent.cli import participant_token
12
- from meshagent.cli import agent
13
- from meshagent.cli import messaging
14
- from meshagent.cli import storage
15
- from meshagent.cli import developer
16
13
  from meshagent.cli import webhook
17
14
  from meshagent.cli import services
18
- from meshagent.cli import cli_secrets
15
+ from meshagent.cli import mailboxes
16
+
19
17
  from meshagent.cli import call
20
18
  from meshagent.cli import cli_mcp
21
19
  from meshagent.cli import chatbot
22
20
  from meshagent.cli import voicebot
23
21
  from meshagent.cli import mailbot
24
- from meshagent.cli import containers
22
+ from meshagent.cli import worker
23
+ from meshagent.cli import task_runner
25
24
  from meshagent.cli import oauth2
26
25
  from meshagent.cli import helpers
27
26
  from meshagent.cli import meeting_transcriber
27
+ from meshagent.cli import rooms
28
28
  from meshagent.cli import room
29
+ from meshagent.cli import port
29
30
  from meshagent.cli.exec import register as register_exec
30
31
  from meshagent.cli.version import __version__
31
32
  from meshagent.cli.helper import get_active_api_key
32
33
  from meshagent.otel import otel_config
33
34
 
34
-
35
35
  from art import tprint
36
36
 
37
37
  import logging
@@ -47,30 +47,31 @@ otel_config(service_name="meshagent-cli")
47
47
  logging.getLogger("openai").setLevel(logging.ERROR)
48
48
  logging.getLogger("httpx").setLevel(logging.ERROR)
49
49
 
50
- app = async_typer.AsyncTyper(no_args_is_help=True)
50
+ app = async_typer.AsyncTyper(no_args_is_help=True, name="meshagent")
51
51
  app.add_typer(call.app, name="call")
52
52
  app.add_typer(auth.app, name="auth")
53
53
  app.add_typer(projects.app, name="project")
54
54
  app.add_typer(api_keys.app, name="api-key")
55
55
  app.add_typer(sessions.app, name="session")
56
56
  app.add_typer(participant_token.app, name="participant-token")
57
- app.add_typer(agent.app, name="agents")
58
- app.add_typer(messaging.app, name="messaging")
59
- app.add_typer(storage.app, name="storage")
60
- app.add_typer(developer.app, name="developer")
61
57
  app.add_typer(webhook.app, name="webhook")
62
58
  app.add_typer(services.app, name="service")
63
- app.add_typer(cli_secrets.app, name="secret")
64
- app.add_typer(queue.app, name="queue")
65
59
  app.add_typer(cli_mcp.app, name="mcp")
66
- app.add_typer(chatbot.app, name="chatbot")
67
- app.add_typer(voicebot.app, name="voicebot")
68
- app.add_typer(mailbot.app, name="mailbot")
69
- app.add_typer(containers.app, name="container")
70
60
  app.add_typer(oauth2.app, name="oauth2")
71
61
  app.add_typer(helpers.app, name="helpers")
72
- app.add_typer(room.app, name="room")
62
+ app.add_typer(rooms.app, name="rooms")
63
+ app.add_typer(mailboxes.app, name="mailbox")
73
64
  app.add_typer(meeting_transcriber.app, name="meeting-transcriber")
65
+ app.add_typer(port.app, name="port")
66
+
67
+ app.add_typer(multi.app, name="multi")
68
+ app.add_typer(voicebot.app, name="voicebot")
69
+ app.add_typer(chatbot.app, name="chatbot")
70
+ app.add_typer(mailbot.app, name="mailbot")
71
+ app.add_typer(task_runner.app, name="task-runner")
72
+ app.add_typer(worker.app, name="worker")
73
+
74
+ app.add_typer(room.app, name="room")
74
75
 
75
76
  register_exec(app)
76
77
 
meshagent/cli/cli_mcp.py CHANGED
@@ -35,23 +35,30 @@ def _kv_to_dict(pairs: List[str]) -> dict[str, str]:
35
35
  return out
36
36
 
37
37
 
38
- app = async_typer.AsyncTyper()
38
+ app = async_typer.AsyncTyper(help="Bridge MCP servers into MeshAgent rooms")
39
39
 
40
40
 
41
- @app.async_command("sse")
41
+ @app.async_command(
42
+ "sse", help="Connect an MCP server over SSE and register it as a toolkit"
43
+ )
42
44
  async def sse(
43
45
  *,
44
- project_id: ProjectIdOption = None,
46
+ project_id: ProjectIdOption,
45
47
  room: RoomOption,
46
48
  name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
47
49
  role: str = "tool",
48
- url: Annotated[str, typer.Option()],
49
- toolkit_name: Annotated[Optional[str], typer.Option()] = None,
50
+ url: Annotated[str, typer.Option(..., help="SSE URL for the MCP server")],
51
+ toolkit_name: Annotated[
52
+ Optional[str],
53
+ typer.Option(help="Toolkit name to register in the room (default: mcp)"),
54
+ ] = None,
50
55
  key: Annotated[
51
56
  str,
52
57
  typer.Option("--key", help="an api key to sign the token with"),
53
58
  ] = None,
54
59
  ):
60
+ """Connect an MCP server over SSE and expose it as a room toolkit."""
61
+
55
62
  from mcp.client.session import ClientSession
56
63
  from mcp.client.sse import sse_client
57
64
 
@@ -116,21 +123,33 @@ async def sse(
116
123
  await account_client.close()
117
124
 
118
125
 
119
- @app.async_command("stdio")
126
+ @app.async_command(
127
+ "stdio", help="Run an MCP server over stdio and register it as a toolkit"
128
+ )
120
129
  async def stdio(
121
130
  *,
122
- project_id: ProjectIdOption = None,
131
+ project_id: ProjectIdOption,
123
132
  room: RoomOption,
124
133
  name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
125
134
  role: str = "tool",
126
- command: Annotated[str, typer.Option()],
127
- toolkit_name: Annotated[Optional[str], typer.Option()] = None,
135
+ command: Annotated[
136
+ str,
137
+ typer.Option(
138
+ ..., help="Command to start an MCP server over stdio (quoted string)"
139
+ ),
140
+ ],
141
+ toolkit_name: Annotated[
142
+ Optional[str],
143
+ typer.Option(help="Toolkit name to register in the room (default: mcp)"),
144
+ ] = None,
128
145
  env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
129
146
  key: Annotated[
130
147
  str,
131
148
  typer.Option("--key", help="an api key to sign the token with"),
132
149
  ] = None,
133
150
  ):
151
+ """Run an MCP server over stdio and expose it as a room toolkit."""
152
+
134
153
  from mcp.client.session import ClientSession
135
154
  from mcp.client.stdio import stdio_client, StdioServerParameters
136
155
 
@@ -205,16 +224,30 @@ async def stdio(
205
224
  await account_client.close()
206
225
 
207
226
 
208
- @app.async_command("http-proxy")
227
+ @app.async_command("http-proxy", help="Expose a stdio MCP server over streamable HTTP")
209
228
  async def stdio_host(
210
229
  *,
211
- command: Annotated[str, typer.Option()],
212
- host: Annotated[Optional[str], typer.Option()] = None,
213
- port: Annotated[Optional[int], typer.Option()] = None,
214
- path: Annotated[Optional[str], typer.Option()] = None,
215
- name: Annotated[Optional[str], typer.Option()] = None,
230
+ command: Annotated[
231
+ str,
232
+ typer.Option(..., help="Command to start the MCP server (stdio transport)"),
233
+ ],
234
+ host: Annotated[
235
+ Optional[str], typer.Option(help="Host to bind the proxy server on")
236
+ ] = None,
237
+ port: Annotated[
238
+ Optional[int], typer.Option(help="Port to bind the proxy server on")
239
+ ] = None,
240
+ path: Annotated[
241
+ Optional[str],
242
+ typer.Option(help="HTTP path to mount the proxy server at"),
243
+ ] = None,
244
+ name: Annotated[
245
+ Optional[str], typer.Option(help="Display name for the proxy server")
246
+ ] = None,
216
247
  env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
217
248
  ):
249
+ """Expose a stdio-based MCP server over streamable HTTP."""
250
+
218
251
  from fastmcp import FastMCP, Client
219
252
  from fastmcp.client.transports import StdioTransport
220
253
 
@@ -238,16 +271,29 @@ async def stdio_host(
238
271
  await proxy.run_async(transport="streamable-http", host=host, port=port, path=path)
239
272
 
240
273
 
241
- @app.async_command("sse-proxy")
274
+ @app.async_command("sse-proxy", help="Expose a stdio MCP server over SSE")
242
275
  async def sse_proxy(
243
276
  *,
244
- command: Annotated[str, typer.Option()],
245
- host: Annotated[Optional[str], typer.Option()] = None,
246
- port: Annotated[Optional[int], typer.Option()] = None,
247
- path: Annotated[Optional[str], typer.Option()] = None,
248
- name: Annotated[Optional[str], typer.Option()] = None,
277
+ command: Annotated[
278
+ str,
279
+ typer.Option(..., help="Command to start the MCP server (stdio transport)"),
280
+ ],
281
+ host: Annotated[
282
+ Optional[str], typer.Option(help="Host to bind the proxy server on")
283
+ ] = None,
284
+ port: Annotated[
285
+ Optional[int], typer.Option(help="Port to bind the proxy server on")
286
+ ] = None,
287
+ path: Annotated[
288
+ Optional[str], typer.Option(help="SSE path to mount the proxy at")
289
+ ] = None,
290
+ name: Annotated[
291
+ Optional[str], typer.Option(help="Display name for the proxy server")
292
+ ] = None,
249
293
  env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
250
294
  ):
295
+ """Expose a stdio-based MCP server over SSE."""
296
+
251
297
  from fastmcp import FastMCP, Client
252
298
  from fastmcp.client.transports import StdioTransport
253
299
 
@@ -271,17 +317,35 @@ async def sse_proxy(
271
317
  await proxy.run_async(transport="sse", host=host, port=port, path=path)
272
318
 
273
319
 
274
- @app.async_command("stdio-service")
320
+ @app.async_command("stdio-service", help="Run a stdio MCP server as an HTTP service")
275
321
  async def stdio_service(
276
322
  *,
277
- command: Annotated[str, typer.Option()],
278
- host: Annotated[Optional[str], typer.Option()] = None,
279
- port: Annotated[Optional[int], typer.Option()] = None,
280
- webhook_secret: Annotated[Optional[str], typer.Option()] = None,
281
- path: Annotated[Optional[str], typer.Option()] = None,
282
- toolkit_name: Annotated[Optional[str], typer.Option()] = None,
323
+ command: Annotated[
324
+ str,
325
+ typer.Option(
326
+ ..., help="Command to start an MCP server over stdio (quoted string)"
327
+ ),
328
+ ],
329
+ host: Annotated[
330
+ Optional[str], typer.Option(help="Host to bind the service on")
331
+ ] = None,
332
+ port: Annotated[
333
+ Optional[int], typer.Option(help="Port to bind the service on")
334
+ ] = None,
335
+ webhook_secret: Annotated[
336
+ Optional[str],
337
+ typer.Option(help="Optional webhook secret for authenticating requests"),
338
+ ] = None,
339
+ path: Annotated[
340
+ Optional[str], typer.Option(help="HTTP path to mount the service at")
341
+ ] = None,
342
+ toolkit_name: Annotated[
343
+ Optional[str], typer.Option(help="Toolkit name to expose (default: mcp)")
344
+ ] = None,
283
345
  env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
284
346
  ):
347
+ """Run a stdio-based MCP server as an HTTP service."""
348
+
285
349
  from mcp.client.session import ClientSession
286
350
  from mcp.client.stdio import stdio_client, StdioServerParameters
287
351
 
@@ -53,7 +53,7 @@ keys_app = async_typer.AsyncTyper(
53
53
  @keys_app.async_command("create")
54
54
  async def create_keys_secret(
55
55
  *,
56
- project_id: ProjectIdOption = None,
56
+ project_id: ProjectIdOption,
57
57
  name: Annotated[str, typer.Option(help="Secret name")],
58
58
  data: Annotated[
59
59
  str,
@@ -86,7 +86,7 @@ async def create_keys_secret(
86
86
  @keys_app.async_command("update")
87
87
  async def update_keys_secret(
88
88
  *,
89
- project_id: ProjectIdOption = None,
89
+ project_id: ProjectIdOption,
90
90
  secret_id: Annotated[str, typer.Option(help="Existing secret ID")],
91
91
  name: Annotated[str, typer.Option(help="Secret name")],
92
92
  data: Annotated[
@@ -128,7 +128,7 @@ docker_app = async_typer.AsyncTyper(
128
128
  @docker_app.async_command("create")
129
129
  async def create_docker_secret(
130
130
  *,
131
- project_id: ProjectIdOption = None,
131
+ project_id: ProjectIdOption,
132
132
  name: Annotated[str, typer.Option(help="Secret name")],
133
133
  server: Annotated[
134
134
  str, typer.Option(help="Docker registry server, e.g. index.docker.io")
@@ -166,7 +166,7 @@ async def create_docker_secret(
166
166
  @docker_app.async_command("update")
167
167
  async def update_docker_secret(
168
168
  *,
169
- project_id: ProjectIdOption = None,
169
+ project_id: ProjectIdOption,
170
170
  secret_id: Annotated[str, typer.Option(help="Existing secret ID")],
171
171
  name: Annotated[str, typer.Option(help="Secret name")],
172
172
  server: Annotated[str, typer.Option(help="Docker registry server")],
@@ -211,7 +211,7 @@ acr_app = async_typer.AsyncTyper(
211
211
  @acr_app.async_command("create")
212
212
  async def create_acr_secret(
213
213
  *,
214
- project_id: ProjectIdOption = None,
214
+ project_id: ProjectIdOption,
215
215
  name: Annotated[str, typer.Option(help="Secret name")],
216
216
  server: Annotated[str, typer.Option(help="ACR server, e.g. myregistry.azurecr.io")],
217
217
  username: Annotated[str, typer.Option(help="Service principal ID")],
@@ -241,7 +241,7 @@ async def create_acr_secret(
241
241
  @acr_app.async_command("update")
242
242
  async def update_acr_secret(
243
243
  *,
244
- project_id: ProjectIdOption = None,
244
+ project_id: ProjectIdOption,
245
245
  secret_id: Annotated[str, typer.Option(help="Existing secret ID")],
246
246
  name: Annotated[str, typer.Option(help="Secret name")],
247
247
  server: Annotated[str, typer.Option(help="ACR server, e.g. myregistry.azurecr.io")],
@@ -281,7 +281,7 @@ gar_app = async_typer.AsyncTyper(
281
281
  @gar_app.async_command("create")
282
282
  async def create_gar_secret(
283
283
  *,
284
- project_id: ProjectIdOption = None,
284
+ project_id: ProjectIdOption,
285
285
  name: Annotated[str, typer.Option(help="Secret name")],
286
286
  server: Annotated[str, typer.Option(help="GAR host, e.g. us-west1-docker.pkg.dev")],
287
287
  json_key: Annotated[
@@ -314,7 +314,7 @@ async def create_gar_secret(
314
314
  @gar_app.async_command("update")
315
315
  async def update_gar_secret(
316
316
  *,
317
- project_id: ProjectIdOption = None,
317
+ project_id: ProjectIdOption,
318
318
  secret_id: Annotated[str, typer.Option(help="Existing secret ID")],
319
319
  name: Annotated[str, typer.Option(help="Secret name")],
320
320
  server: Annotated[str, typer.Option(help="GAR host, e.g. us-west1-docker.pkg.dev")],
@@ -348,7 +348,7 @@ async def update_gar_secret(
348
348
 
349
349
 
350
350
  @secrets_app.async_command("list")
351
- async def secret_list(*, project_id: ProjectIdOption = None):
351
+ async def secret_list(*, project_id: ProjectIdOption):
352
352
  """List all secrets in the project (typed as Docker/ACR/GAR or Keys secrets)."""
353
353
  client = await get_client()
354
354
  try:
@@ -383,7 +383,7 @@ async def secret_list(*, project_id: ProjectIdOption = None):
383
383
  @secrets_app.async_command("delete")
384
384
  async def secret_delete(
385
385
  *,
386
- project_id: ProjectIdOption = None,
386
+ project_id: ProjectIdOption,
387
387
  secret_id: Annotated[str, typer.Argument(help="ID of the secret to delete")],
388
388
  ):
389
389
  """Delete a secret."""
@@ -1,28 +1,43 @@
1
1
  import typer
2
2
  from typing import Annotated, Optional
3
+ import os
4
+ from meshagent.cli.helper import _load_settings
3
5
 
4
6
  OutputFormatOption = Annotated[
5
7
  str,
6
8
  typer.Option("--output", "-o", help="output format [json|table]"),
7
9
  ]
8
10
 
11
+ if os.getenv("MESHAGENT_CLI_BUILD"):
12
+ default_project_id = None
13
+ else:
14
+ settings = _load_settings()
15
+ if settings is None:
16
+ default_project_id = None
17
+ else:
18
+ default_project_id = settings.active_project
19
+
20
+
21
+ def get_default_project_id():
22
+ return os.getenv("MESHAGENT_PROJECT_ID") or default_project_id
23
+
24
+
9
25
  ProjectIdOption = Annotated[
10
26
  Optional[str],
11
27
  typer.Option(
12
28
  "--project-id",
13
29
  help="A MeshAgent project id. If empty, the activated project will be used.",
30
+ default_factory=get_default_project_id(),
14
31
  ),
15
32
  ]
16
33
 
17
34
  RoomOption = Annotated[
18
- str,
35
+ Optional[str],
19
36
  typer.Option(
20
- "--room",
21
- help="Room name",
37
+ "--room", help="Room name", default_factory=os.getenv("MESHAGENT_ROOM")
22
38
  ),
23
39
  ]
24
40
 
25
-
26
41
  RoomCreateOption = Annotated[
27
42
  bool,
28
43
  typer.Option(
@@ -33,6 +33,8 @@ from meshagent.api.room_server_client import (
33
33
  DockerSecret,
34
34
  )
35
35
 
36
+ import sys
37
+
36
38
  app = async_typer.AsyncTyper(help="Manage containers and images inside a room")
37
39
 
38
40
  # -------------------------
@@ -353,7 +355,6 @@ async def _with_client(
353
355
 
354
356
  connection = await account_client.connect_room(project_id=project_id, room=room)
355
357
 
356
- print("[bold green]Connecting to room...[/bold green]", flush=True)
357
358
  proto = WebSocketClientProtocol(
358
359
  url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
359
360
  token=connection.jwt,
@@ -374,7 +375,7 @@ async def _with_client(
374
375
  @app.async_command("ps")
375
376
  async def list_containers(
376
377
  *,
377
- project_id: ProjectIdOption = None,
378
+ project_id: ProjectIdOption,
378
379
  room: RoomOption,
379
380
  output: Annotated[Optional[str], typer.Option(help="json | table")] = "json",
380
381
  ):
@@ -407,7 +408,7 @@ async def list_containers(
407
408
  @app.async_command("stop")
408
409
  async def stop_container(
409
410
  *,
410
- project_id: ProjectIdOption = None,
411
+ project_id: ProjectIdOption,
411
412
  room: RoomOption,
412
413
  id: Annotated[str, typer.Option(..., help="Container ID")],
413
414
  ):
@@ -426,10 +427,12 @@ async def stop_container(
426
427
  @app.async_command("logs")
427
428
  async def container_logs(
428
429
  *,
429
- project_id: ProjectIdOption = None,
430
+ project_id: ProjectIdOption,
430
431
  room: RoomOption,
431
432
  id: Annotated[str, typer.Option(..., help="Container ID")],
432
- follow: Annotated[bool, typer.Option("--follow/--no-follow")] = False,
433
+ follow: Annotated[
434
+ bool, typer.Option("--follow/--no-follow", help="Stream logs")
435
+ ] = False,
433
436
  ):
434
437
  account_client, client = await _with_client(
435
438
  project_id=project_id,
@@ -443,18 +446,151 @@ async def container_logs(
443
446
  await account_client.close()
444
447
 
445
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
+
446
579
  # -------------------------
447
- # Run (detached) and run-attached
580
+ # Run (detached)
448
581
  # -------------------------
449
582
 
450
583
 
451
584
  @app.async_command("run")
452
585
  async def run_container(
453
586
  *,
454
- project_id: ProjectIdOption = None,
587
+ project_id: ProjectIdOption,
455
588
  room: RoomOption,
456
589
  image: Annotated[str, typer.Option(..., help="Image to run")],
457
- command: Annotated[Optional[str], typer.Option(...)] = None,
590
+ command: Annotated[
591
+ Optional[str],
592
+ typer.Option(..., help="Command to execute in the container (quoted string)"),
593
+ ] = None,
458
594
  env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
459
595
  port: Annotated[
460
596
  List[str], typer.Option("--port", "-p", help="CONTAINER:HOST")
@@ -470,11 +606,23 @@ async def run_container(
470
606
  help="Docker creds (username,password) or (registry,username,password)",
471
607
  ),
472
608
  ] = [],
473
- mount_path: Annotated[Optional[str], typer.Option()] = None,
474
- mount_subpath: Annotated[Optional[str], typer.Option()] = None,
475
- participant_name: Annotated[Optional[str], typer.Option()] = None,
476
- role: Annotated[str, typer.Option(...)] = "user",
477
- container_name: Annotated[str, typer.Option(...)] = None,
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,
478
626
  ):
479
627
  account_client, client = await _with_client(
480
628
  project_id=project_id,
@@ -517,7 +665,7 @@ app.add_typer(images_app, name="images")
517
665
  @images_app.async_command("list")
518
666
  async def images_list(
519
667
  *,
520
- project_id: ProjectIdOption = None,
668
+ project_id: ProjectIdOption,
521
669
  room: RoomOption,
522
670
  ):
523
671
  account_client, client = await _with_client(
@@ -535,7 +683,7 @@ async def images_list(
535
683
  @images_app.async_command("delete")
536
684
  async def images_delete(
537
685
  *,
538
- project_id: ProjectIdOption = None,
686
+ project_id: ProjectIdOption,
539
687
  room: RoomOption,
540
688
  image: Annotated[str, typer.Option(..., help="Image ref/tag to delete")],
541
689
  ):
@@ -554,7 +702,7 @@ async def images_delete(
554
702
  @images_app.async_command("pull")
555
703
  async def images_pull(
556
704
  *,
557
- project_id: ProjectIdOption = None,
705
+ project_id: ProjectIdOption,
558
706
  room: RoomOption,
559
707
  tag: Annotated[str, typer.Option(..., help="Image tag/ref to pull")],
560
708
  cred: Annotated[