meshagent-cli 0.5.15__py3-none-any.whl → 0.6.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.

Potentially problematic release.


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

meshagent/cli/services.py CHANGED
@@ -3,93 +3,44 @@
3
3
  # ---------------------------------------------------------------------------
4
4
  import typer
5
5
  from rich import print
6
- from typing import Annotated, List, Optional, Dict
7
- from meshagent.cli.common_options import ProjectIdOption, ApiKeyIdOption
6
+ from typing import Annotated, Optional
7
+ from meshagent.cli.common_options import ProjectIdOption
8
8
  from aiohttp import ClientResponseError
9
- from datetime import datetime, timezone
10
- from pydantic import PositiveInt
11
- import pydantic
12
- from typing import Literal
9
+ import pathlib
13
10
  from meshagent.cli import async_typer
11
+ from meshagent.api.services import well_known_service_path
14
12
  from meshagent.api.specs.service import ServiceSpec
13
+ from meshagent.api.keys import parse_api_key
14
+
15
+ import asyncio
16
+ import shlex
17
+
18
+ import os
19
+ import signal
20
+ import atexit
21
+ import ctypes
22
+ import sys
15
23
 
16
24
 
17
25
  from meshagent.cli.helper import (
18
26
  get_client,
19
27
  print_json_table,
20
28
  resolve_project_id,
21
- resolve_api_key,
22
29
  resolve_room,
30
+ resolve_key,
23
31
  )
24
32
  from meshagent.api import (
25
33
  ParticipantToken,
26
- RoomClient,
27
- WebSocketClientProtocol,
28
- websocket_room_url,
29
- meshagent_base_url,
34
+ ApiScope,
30
35
  )
31
36
  from meshagent.cli.common_options import OutputFormatOption
32
37
 
33
38
  from pydantic_yaml import parse_yaml_raw_as
34
39
 
35
- # Pydantic basemodels
36
- from meshagent.api.accounts_client import Service, Port, Services
37
-
38
- app = async_typer.AsyncTyper(help="Manage services for your project")
39
-
40
- # ---------------------------------------------------------------------------
41
- # Utilities
42
- # ---------------------------------------------------------------------------
43
-
44
-
45
- def _kv_to_dict(pairs: List[str]) -> Dict[str, str]:
46
- """Convert ["A=1","B=2"] → {"A":"1","B":"2"}."""
47
- out: Dict[str, str] = {}
48
- for p in pairs:
49
- if "=" not in p:
50
- raise typer.BadParameter(f"'{p}' must be KEY=VALUE")
51
- k, v = p.split("=", 1)
52
- out[k] = v
53
- return out
54
-
55
-
56
- class PortSpec(pydantic.BaseModel):
57
- """
58
- CLI schema for --port.
59
- Example:
60
- --port num=8080 type=webserver liveness=/health path=/agent participant_name=myname
61
- """
62
-
63
- num: PositiveInt | Literal["*"]
64
- type: Literal["mcp.sse", "meshagent.callable", "http", "tcp"]
65
- liveness: str | None = None
66
- participant_name: str | None = None
67
- path: str | None = None
68
40
 
41
+ from meshagent.cli.call import _make_call
69
42
 
70
- def _parse_port_spec(spec: str) -> PortSpec:
71
- """
72
- Convert "num=8080 type=webserver liveness=/health" → PortSpec.
73
- The user should quote the whole string if it contains spaces.
74
- """
75
- tokens = spec.strip().split()
76
- kv: Dict[str, str] = {}
77
- for t in tokens:
78
- if "=" not in t:
79
- raise typer.BadParameter(
80
- f"expected num=PORT_NUMBER type=meshagent.callable|mcp.sse liveness=OPTIONAL_PATH, got '{t}'"
81
- )
82
- k, v = t.split("=", 1)
83
- kv[k] = v
84
- try:
85
- return PortSpec(**kv)
86
- except pydantic.ValidationError as exc:
87
- raise typer.BadParameter(str(exc))
88
-
89
-
90
- # ---------------------------------------------------------------------------
91
- # Commands
92
- # ---------------------------------------------------------------------------
43
+ app = async_typer.AsyncTyper(help="Manage services for your project")
93
44
 
94
45
 
95
46
  @app.async_command("create")
@@ -97,100 +48,40 @@ async def service_create(
97
48
  *,
98
49
  project_id: ProjectIdOption = None,
99
50
  file: Annotated[
100
- Optional[str],
51
+ str,
101
52
  typer.Option("--file", "-f", help="File path to a service definition"),
102
- ] = None,
103
- name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
104
- image: Annotated[
105
- Optional[str], typer.Option(help="Container image reference")
106
- ] = None,
107
- role: Annotated[
108
- Optional[str], typer.Option(help="Service role (agent|tool)")
109
- ] = None,
110
- pull_secret: Annotated[
111
- Optional[str],
112
- typer.Option("--pull-secret", help="Secret ID for registry"),
113
- ] = None,
114
- command: Annotated[
115
- Optional[str],
116
- typer.Option("--command", help="Override ENTRYPOINT/CMD"),
117
- ] = None,
118
- env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
119
- env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
120
- runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
121
- room_storage_path: Annotated[
122
- Optional[str],
123
- typer.Option("--mount", help="Path inside container to mount room storage"),
124
- ] = None,
125
- room_storage_subpath: Annotated[
53
+ ],
54
+ room: Annotated[
126
55
  Optional[str],
127
56
  typer.Option(
128
- "--mount-subpath",
129
- help="Restrict the container's mount to a subpath within the room storage",
57
+ "--room", "-r", help="The name of a room to create the service for"
130
58
  ),
131
59
  ] = None,
132
- port: Annotated[
133
- List[str],
134
- typer.Option(
135
- "--port",
136
- "-p",
137
- help=(
138
- "Repeatable. Example:\n"
139
- ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
140
- ),
141
- ),
142
- ] = [],
143
60
  ):
144
61
  """Create a service attached to the project."""
145
62
  client = await get_client()
146
63
  try:
147
64
  project_id = await resolve_project_id(project_id)
148
65
 
149
- if file is not None:
150
- with open(file, "rb") as f:
151
- spec = parse_yaml_raw_as(ServiceSpec, f.read())
152
- if spec.id is not None:
153
- print("[red]id cannot be set when creating a service[/red]")
154
- raise typer.Exit(code=1)
66
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
67
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
155
68
 
156
- service_obj = spec.to_service()
157
-
158
- else:
159
- # ✅ validate / coerce port specs
160
- port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
161
-
162
- ports_dict = {
163
- ps.num: Port(
164
- type=ps.type,
165
- liveness_path=ps.liveness,
166
- participant_name=ps.participant_name,
167
- path=ps.path,
168
- )
169
- for ps in port_specs
170
- } or None
171
-
172
- service_obj = Service(
173
- created_at=datetime.now(timezone.utc).isoformat(),
174
- name=name,
175
- role=role,
176
- image=image,
177
- command=command,
178
- pull_secret=pull_secret,
179
- room_storage_path=room_storage_path,
180
- room_storage_subpath=room_storage_subpath,
181
- environment=_kv_to_dict(env),
182
- environment_secrets=env_secret or None,
183
- runtime_secrets=_kv_to_dict(runtime_secret),
184
- ports=ports_dict,
185
- )
69
+ if spec.id is not None:
70
+ print("[red]id cannot be set when creating a service[/red]")
71
+ raise typer.Exit(code=1)
186
72
 
187
73
  try:
188
- new_id = (
189
- await client.create_service(project_id=project_id, service=service_obj)
190
- )["id"]
74
+ if room is None:
75
+ new_id = await client.create_service(
76
+ project_id=project_id, service=spec
77
+ )
78
+ else:
79
+ new_id = await client.create_room_service(
80
+ project_id=project_id, service=spec, room_name=room
81
+ )
191
82
  except ClientResponseError as exc:
192
83
  if exc.status == 409:
193
- print(f"[red]Service name already in use: {service_obj.name}[/red]")
84
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
194
85
  raise typer.Exit(code=1)
195
86
  raise
196
87
  else:
@@ -206,102 +97,43 @@ async def service_update(
206
97
  project_id: ProjectIdOption = None,
207
98
  id: Optional[str] = None,
208
99
  file: Annotated[
209
- Optional[str],
100
+ str,
210
101
  typer.Option("--file", "-f", help="File path to a service definition"),
211
- ] = None,
212
- name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
213
- image: Annotated[
214
- Optional[str], typer.Option(help="Container image reference")
215
- ] = None,
216
- role: Annotated[
217
- Optional[str], typer.Option(help="Service role (agent|tool)")
218
- ] = None,
219
- pull_secret: Annotated[
220
- Optional[str],
221
- typer.Option("--pull-secret", help="Secret ID for registry"),
222
- ] = None,
223
- command: Annotated[
224
- Optional[str],
225
- typer.Option("--command", help="Override ENTRYPOINT/CMD"),
226
- ] = None,
227
- env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
228
- env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
229
- runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
230
- room_storage_path: Annotated[
231
- Optional[str],
232
- typer.Option("--mount", help="Path inside container to mount room storage"),
233
- ] = None,
234
- room_storage_subpath: Annotated[
235
- Optional[str],
236
- typer.Option(
237
- "--mount-subpath",
238
- help="Restrict the container's mount to a subpath within the room storage",
239
- ),
240
- ] = None,
241
- port: Annotated[
242
- List[str],
243
- typer.Option(
244
- "--port",
245
- "-p",
246
- help=(
247
- "Repeatable. Example:\n"
248
- ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
249
- ),
250
- ),
251
- ] = [],
102
+ ],
252
103
  create: Annotated[
253
104
  Optional[bool],
254
105
  typer.Option(
255
106
  help="create the service if it does not exist",
256
107
  ),
257
108
  ] = False,
109
+ room: Annotated[
110
+ Optional[str],
111
+ typer.Option(
112
+ "--room", "-r", help="The name of a room to update the service for"
113
+ ),
114
+ ] = None,
258
115
  ):
259
116
  """Create a service attached to the project."""
260
117
  client = await get_client()
261
118
  try:
262
119
  project_id = await resolve_project_id(project_id)
263
120
 
264
- if file is not None:
265
- with open(file, "rb") as f:
266
- spec = parse_yaml_raw_as(ServiceSpec, f.read())
267
- if spec.id is not None:
268
- id = spec.id
269
- service_obj = spec.to_service()
270
-
271
- else:
272
- # ✅ validate / coerce port specs
273
- port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
274
-
275
- ports_dict = {
276
- ps.num: Port(
277
- type=ps.type,
278
- liveness_path=ps.liveness,
279
- participant_name=ps.participant_name,
280
- path=ps.path,
281
- )
282
- for ps in port_specs
283
- } or None
284
-
285
- service_obj = Service(
286
- created_at=datetime.now(timezone.utc).isoformat(),
287
- name=name,
288
- role=role,
289
- image=image,
290
- command=command,
291
- pull_secret=pull_secret,
292
- room_storage_path=room_storage_path,
293
- room_storage_subpath=room_storage_subpath,
294
- environment=_kv_to_dict(env),
295
- environment_secrets=env_secret or None,
296
- runtime_secrets=_kv_to_dict(runtime_secret),
297
- ports=ports_dict,
298
- )
121
+ with open(str(pathlib.Path(file).expanduser().resolve()), "rb") as f:
122
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
123
+ if spec.id is not None:
124
+ id = spec.id
299
125
 
300
126
  try:
301
127
  if id is None:
302
- services = await client.list_services(project_id=project_id)
128
+ if room is None:
129
+ services = await client.list_services(project_id=project_id)
130
+ else:
131
+ services = await client.list_room_services(
132
+ project_id=project_id, room_name=room
133
+ )
134
+
303
135
  for s in services:
304
- if s.name == service_obj.name:
136
+ if s.metadata.name == spec.metadata.name:
305
137
  id = s.id
306
138
 
307
139
  if id is None and not create:
@@ -309,20 +141,32 @@ async def service_update(
309
141
  raise typer.Exit(code=1)
310
142
 
311
143
  if id is None:
312
- id = (
313
- await client.create_service(
314
- project_id=project_id, service=service_obj
144
+ if room is None:
145
+ id = await client.create_service(
146
+ project_id=project_id, service=spec
147
+ )
148
+ else:
149
+ id = await client.create_room_service(
150
+ project_id=project_id, service=spec, room_name=room
315
151
  )
316
- )["id"]
317
152
 
318
153
  else:
319
- await client.update_service(
320
- project_id=project_id, service_id=id, service=service_obj
321
- )
154
+ spec.id = id
155
+ if room is None:
156
+ await client.update_service(
157
+ project_id=project_id, service_id=id, service=spec
158
+ )
159
+ else:
160
+ await client.update_room_service(
161
+ project_id=project_id,
162
+ service_id=id,
163
+ service=spec,
164
+ room_name=room,
165
+ )
322
166
 
323
167
  except ClientResponseError as exc:
324
168
  if exc.status == 409:
325
- print(f"[red]Service name already in use: {service_obj.name}[/red]")
169
+ print(f"[red]Service name already in use: {spec.metadata.name}[/red]")
326
170
  raise typer.Exit(code=1)
327
171
  raise
328
172
  else:
@@ -332,137 +176,142 @@ async def service_update(
332
176
  await client.close()
333
177
 
334
178
 
335
- @app.async_command("test")
336
- async def service_test(
179
+ @app.async_command("run")
180
+ async def service_run(
337
181
  *,
338
182
  project_id: ProjectIdOption = None,
339
- api_key_id: ApiKeyIdOption = None,
340
- file: Annotated[
341
- Optional[str],
342
- typer.Option("--file", "-f", help="File path to a service definition"),
343
- ],
344
- room: Annotated[
345
- Optional[str],
346
- typer.Option(
347
- help="A room name to test the service in (must not be currently running)"
348
- ),
349
- ] = None,
350
- name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
351
- role: Annotated[
352
- Optional[str], typer.Option(help="Service role (agent|tool)")
353
- ] = None,
354
- image: Annotated[
355
- Optional[str], typer.Option(help="Container image reference")
356
- ] = None,
357
- pull_secret: Annotated[
358
- Optional[str],
359
- typer.Option("--pull-secret", help="Secret ID for registry"),
360
- ] = None,
361
- command: Annotated[
362
- Optional[str],
363
- typer.Option("--command", help="Override ENTRYPOINT/CMD"),
364
- ] = None,
365
- env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
366
- env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
367
- runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
368
- room_storage_path: Annotated[
369
- Optional[str],
370
- typer.Option("--mount", help="Path inside container to mount room storage"),
371
- ] = None,
183
+ command: str,
372
184
  port: Annotated[
373
- List[str],
185
+ int,
374
186
  typer.Option(
375
187
  "--port",
376
188
  "-p",
377
189
  help=(
378
- "Repeatable. Example:\n"
379
- ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
190
+ "a port number to run the agent on (will set MESHAGENT_PORT environment variable when launching the service)"
380
191
  ),
381
192
  ),
382
- ] = [],
383
- timeout: Annotated[
384
- Optional[int],
193
+ ] = None,
194
+ room: Annotated[
195
+ Optional[str],
385
196
  typer.Option(
386
- "--timeout", help="The maximum time that this room should run (default 1hr)"
197
+ help="A room name to test the service in (must not be currently running)"
387
198
  ),
388
199
  ] = None,
200
+ key: Annotated[
201
+ str,
202
+ typer.Option("--key", help="an api key to sign the token with"),
203
+ ] = None,
389
204
  ):
390
- """Create a service attached to the project."""
205
+ key = await resolve_key(project_id=project_id, key=key)
206
+
207
+ if port is None:
208
+ import socket
209
+
210
+ def find_free_port():
211
+ with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
212
+ s.bind(("", 0)) # Bind to a free port provided by the host.
213
+ s.listen(1)
214
+ return s.getsockname()[1]
215
+
216
+ port = find_free_port()
217
+
391
218
  my_client = await get_client()
392
219
  try:
393
220
  project_id = await resolve_project_id(project_id)
394
- api_key_id = await resolve_api_key(project_id, api_key_id)
395
221
  room = resolve_room(room)
396
222
 
397
- if file is not None:
398
- with open(file, "rb") as f:
399
- service_obj = parse_yaml_raw_as(ServiceSpec, f.read()).to_service()
400
-
401
- else:
402
- # ✅ validate / coerce port specs
403
- port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
404
-
405
- ports_dict = {
406
- str(ps.num): Port(
407
- type=ps.type,
408
- liveness_path=ps.liveness,
409
- participant_name=ps.participant_name,
410
- path=ps.path,
411
- )
412
- for ps in port_specs
413
- } or None
414
-
415
- service_obj = Service(
416
- created_at=datetime.now(timezone.utc).isoformat(),
417
- role=role,
418
- name=name,
419
- image=image,
420
- command=command,
421
- pull_secret=pull_secret,
422
- room_storage_path=room_storage_path,
423
- environment=_kv_to_dict(env),
424
- environment_secrets=env_secret or None,
425
- runtime_secrets=_kv_to_dict(runtime_secret),
426
- ports=ports_dict,
427
- )
223
+ if room is None:
224
+ print("[bold red]Room was not set[/bold red]")
225
+ raise typer.Exit(1)
428
226
 
429
227
  try:
228
+ parsed_key = parse_api_key(key)
430
229
  token = ParticipantToken(
431
- name=name, project_id=project_id, api_key_id=api_key_id
230
+ name="cli", project_id=project_id, api_key_id=parsed_key.id
432
231
  )
232
+ token.add_api_grant(ApiScope.agent_default())
433
233
  token.add_role_grant("user")
434
234
  token.add_room_grant(room)
435
- token.extra_payload = {
436
- "max_runtime_seconds": timeout, # run for 1 hr max
437
- "meshagent_dev_services": [service_obj.model_dump(mode="json")],
438
- }
439
235
 
440
236
  print("[bold green]Connecting to room...[/bold green]")
441
237
 
442
- key = (
443
- await my_client.decrypt_project_api_key(
444
- project_id=project_id, id=api_key_id
445
- )
446
- )["token"]
447
-
448
- async with RoomClient(
449
- protocol=WebSocketClientProtocol(
450
- url=websocket_room_url(
451
- room_name=room, base_url=meshagent_base_url()
452
- ),
453
- token=token.to_jwt(token=key),
454
- )
455
- ) as client:
456
- print(
457
- f"[green]Your test room '{client.room_name}' has been started. It will time out after a few minutes if you do not join it.[/green]"
238
+ run_tasks = []
239
+
240
+ async def run_service(port: int):
241
+ code, output = await _run_process(
242
+ cmd=shlex.split("python3 " + command),
243
+ log=True,
244
+ env={**os.environ, "MESHAGENT_PORT": str(port)},
458
245
  )
459
246
 
247
+ if code != 0:
248
+ print(f"[red]{output}[/red]")
249
+
250
+ run_tasks.append(asyncio.create_task(run_service(port)))
251
+
252
+ async def get_spec(port: int, attempt=0) -> ServiceSpec:
253
+ import aiohttp
254
+
255
+ max_attempts = 10
256
+
257
+ url = f"http://localhost:{port}{well_known_service_path}"
258
+
259
+ async with aiohttp.ClientSession() as session:
260
+ try:
261
+ res = await session.get(url=url)
262
+ res.raise_for_status()
263
+
264
+ spec_json = await res.json()
265
+
266
+ return ServiceSpec.model_validate(spec_json)
267
+
268
+ except Exception:
269
+ if attempt < max_attempts:
270
+ backoff = 0.1 * pow(2, attempt)
271
+ await asyncio.sleep(backoff)
272
+ return await get_spec(port, attempt + 1)
273
+ else:
274
+ print("[red]unable to read service spec[/red]")
275
+ raise typer.Exit(-1)
276
+
277
+ spec = await get_spec(port)
278
+
279
+ sys.stdout.write("\n")
280
+
281
+ for p in spec.ports:
282
+ print(f"[bold green]Connecting port {p.num}...[/bold green]")
283
+
284
+ for endpoint in p.endpoints:
285
+ print(
286
+ f"[bold green]Connecting endpoint {endpoint.path}...[/bold green]"
287
+ )
288
+
289
+ run_tasks.append(
290
+ asyncio.create_task(
291
+ _make_call(
292
+ room=room,
293
+ project_id=project_id,
294
+ participant_name=endpoint.meshagent.identity,
295
+ url=f"http://localhost:{p.num}{endpoint.path}",
296
+ arguments={},
297
+ key=key,
298
+ permissions=endpoint.meshagent.api,
299
+ )
300
+ )
301
+ )
302
+
303
+ await asyncio.gather(*run_tasks)
304
+
460
305
  except ClientResponseError as exc:
461
306
  if exc.status == 409:
462
307
  print(f"[red]Room already in use: {room}[/red]")
463
308
  raise typer.Exit(code=1)
464
309
  raise
465
310
 
311
+ except Exception as e:
312
+ print(f"[red]{e}[/red]")
313
+ raise typer.Exit(code=1)
314
+
466
315
  finally:
467
316
  await my_client.close()
468
317
 
@@ -471,7 +320,7 @@ async def service_test(
471
320
  async def service_show(
472
321
  *,
473
322
  project_id: ProjectIdOption = None,
474
- service_id: Annotated[str, typer.Argument(help="ID of the service to delete")],
323
+ service_id: Annotated[str, typer.Argument(help="ID of the service to show")],
475
324
  ):
476
325
  """Show a services for the project."""
477
326
  client = await get_client()
@@ -490,20 +339,44 @@ async def service_list(
490
339
  *,
491
340
  project_id: ProjectIdOption = None,
492
341
  o: OutputFormatOption = "table",
342
+ room: Annotated[
343
+ Optional[str],
344
+ typer.Option(
345
+ "--room", "-r", help="The name of a room to list the services for"
346
+ ),
347
+ ] = None,
493
348
  ):
494
349
  """List all services for the project."""
495
350
  client = await get_client()
496
351
  try:
497
352
  project_id = await resolve_project_id(project_id)
498
- services: list[Service] = await client.list_services(
499
- project_id=project_id
500
- ) # List[Service]
353
+ services: list[ServiceSpec] = (
354
+ (await client.list_services(project_id=project_id))
355
+ if room is None
356
+ else (
357
+ await client.list_room_services(project_id=project_id, room_name=room)
358
+ )
359
+ )
501
360
 
502
361
  if o == "json":
503
- print(Services(services=services).model_dump_json(indent=2))
362
+ print(
363
+ {"services": [svc.model_dump(mode="json") for svc in services]}
364
+ ).model_dump_json(indent=2)
504
365
  else:
505
366
  print_json_table(
506
- [svc.model_dump(mode="json") for svc in services], "id", "name", "image"
367
+ [
368
+ {
369
+ "id": svc.id,
370
+ "name": svc.metadata.name,
371
+ "image": svc.container.image
372
+ if svc.container is not None
373
+ else None,
374
+ }
375
+ for svc in services
376
+ ],
377
+ "id",
378
+ "name",
379
+ "image",
507
380
  )
508
381
  finally:
509
382
  await client.close()
@@ -514,12 +387,104 @@ async def service_delete(
514
387
  *,
515
388
  project_id: ProjectIdOption = None,
516
389
  service_id: Annotated[str, typer.Argument(help="ID of the service to delete")],
390
+ room: Annotated[
391
+ Optional[str],
392
+ typer.Option(
393
+ "--room", "-r", help="The name of a room to delete the service for"
394
+ ),
395
+ ] = None,
517
396
  ):
518
397
  """Delete a service."""
519
398
  client = await get_client()
520
399
  try:
521
400
  project_id = await resolve_project_id(project_id)
522
- await client.delete_service(project_id=project_id, service_id=service_id)
401
+ if room is None:
402
+ await client.delete_service(project_id=project_id, service_id=service_id)
403
+ else:
404
+ await client.delete_service(
405
+ project_id=project_id, service_id=service_id, room_name=room
406
+ )
523
407
  print(f"[green]Service {service_id} deleted.[/]")
524
408
  finally:
525
409
  await client.close()
410
+
411
+
412
+ async def _run_process(
413
+ cmd: list[str], cwd=None, env=None, timeout: float | None = None, log: bool = False
414
+ ) -> tuple[int, str]:
415
+ """
416
+ Spawn a process, stream its output line-by-line as it runs, and return its exit code.
417
+ stdout+stderr are merged to preserve ordering.
418
+ """
419
+ proc = await asyncio.create_subprocess_exec(
420
+ *cmd,
421
+ cwd=cwd,
422
+ env=env,
423
+ stdout=asyncio.subprocess.PIPE,
424
+ stderr=asyncio.subprocess.STDOUT,
425
+ preexec_fn=_preexec_fn,
426
+ )
427
+
428
+ _spawned.append(proc)
429
+
430
+ output = []
431
+ try:
432
+ # Stream lines as they appear
433
+ assert proc.stdout is not None
434
+ while True:
435
+ line = (
436
+ await asyncio.wait_for(proc.stdout.readline(), timeout=timeout)
437
+ if timeout
438
+ else await proc.stdout.readline()
439
+ )
440
+ if not line:
441
+ break
442
+ ln = line.decode(errors="replace").rstrip()
443
+ if log:
444
+ print(ln, flush=True)
445
+ output.append(ln) # or send to a logger/queue
446
+
447
+ return await proc.wait(), "".join(output)
448
+ except asyncio.TimeoutError:
449
+ # Graceful shutdown on timeout
450
+ proc.terminate()
451
+ try:
452
+ await asyncio.wait_for(proc.wait(), 5)
453
+ except asyncio.TimeoutError:
454
+ proc.kill()
455
+ await proc.wait()
456
+ raise
457
+
458
+
459
+ # Linux-only: send SIGTERM to child if parent dies
460
+ _PRCTL_AVAILABLE = sys.platform.startswith("linux")
461
+ if _PRCTL_AVAILABLE:
462
+ libc = ctypes.CDLL("libc.so.6", use_errno=True)
463
+ PR_SET_PDEATHSIG = 1
464
+
465
+
466
+ def _preexec_fn():
467
+ # Make child the leader of a new session/process group
468
+ os.setsid()
469
+ # On Linux, ensure child gets SIGTERM if parent dies unexpectedly
470
+ if _PRCTL_AVAILABLE:
471
+ if libc.prctl(PR_SET_PDEATHSIG, signal.SIGTERM) != 0:
472
+ err = ctypes.get_errno()
473
+ raise OSError(err, "prctl(PR_SET_PDEATHSIG) failed")
474
+
475
+
476
+ _spawned = []
477
+
478
+
479
+ def _cleanup():
480
+ # Kill each child's process group (created by setsid)
481
+ for p in _spawned:
482
+ try:
483
+ os.killpg(p.pid, signal.SIGTERM)
484
+ except ProcessLookupError:
485
+ pass
486
+ except Exception:
487
+ pass
488
+
489
+
490
+ atexit.register(_cleanup)