meshagent-cli 0.0.39__py3-none-any.whl → 0.2.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/call.py CHANGED
@@ -72,10 +72,16 @@ async def make_call(
72
72
  project_id: str = None,
73
73
  room: Annotated[str, typer.Option()],
74
74
  api_key_id: Annotated[Optional[str], typer.Option()] = None,
75
- name: Annotated[str, typer.Option(..., help="Participant name")] = "cli",
76
75
  role: str = "agent",
77
76
  local: Optional[bool] = None,
78
- agent_name: Annotated[str, typer.Option(..., help="Name of the agent to call")],
77
+ agent_name: Annotated[
78
+ Optional[str], typer.Option(..., help="deprecated and unused", hidden=True)
79
+ ] = None,
80
+ name: Annotated[str, typer.Option(..., help="deprecated", hidden=True)] = None,
81
+ participant_name: Annotated[
82
+ Optional[str],
83
+ typer.Option(..., help="the participant name to be used by the callee"),
84
+ ] = None,
79
85
  url: Annotated[str, typer.Option(..., help="URL the agent should call")],
80
86
  arguments: Annotated[
81
87
  str, typer.Option(..., help="JSON string with arguments for the call")
@@ -83,7 +89,22 @@ async def make_call(
83
89
  ):
84
90
  """
85
91
  Instruct an agent to 'call' a given URL with specific arguments.
92
+
86
93
  """
94
+
95
+ if name is not None:
96
+ print("[yellow]name is deprecated and should no longer be passed[/yellow]")
97
+
98
+ if agent_name is not None:
99
+ print(
100
+ "[yellow]agent-name is deprecated and should no longer be passed, use participant-name instead[/yellow]"
101
+ )
102
+ participant_name = agent_name
103
+
104
+ if participant_name is None:
105
+ print("[red]--participant-name is required[/red]")
106
+ raise typer.Exit(1)
107
+
87
108
  account_client = await get_client()
88
109
  try:
89
110
  project_id = await resolve_project_id(project_id=project_id)
@@ -96,7 +117,7 @@ async def make_call(
96
117
  )["token"]
97
118
 
98
119
  token = ParticipantToken(
99
- name=name, project_id=project_id, api_key_id=api_key_id
120
+ name=participant_name, project_id=project_id, api_key_id=api_key_id
100
121
  )
101
122
  token.add_role_grant(role=role)
102
123
  token.add_room_grant(room)
@@ -130,7 +151,7 @@ async def make_call(
130
151
  ) as client:
131
152
  print("[bold green]Making agent call...[/bold green]")
132
153
  await client.agents.make_call(
133
- name=agent_name, url=url, arguments=json.loads(arguments)
154
+ name=participant_name, url=url, arguments=json.loads(arguments)
134
155
  )
135
156
  print("[bold cyan]Call request sent successfully.[/bold cyan]")
136
157
 
meshagent/cli/cli.py CHANGED
@@ -2,6 +2,8 @@ import typer
2
2
  import asyncio
3
3
  from typing import Optional
4
4
 
5
+ from meshagent.cli import async_typer
6
+
5
7
  from meshagent.cli import auth
6
8
  from meshagent.cli import api_keys
7
9
  from meshagent.cli import projects
@@ -18,7 +20,7 @@ from meshagent.cli import call
18
20
  from meshagent.cli import cli_mcp
19
21
  from meshagent.cli import chatbot
20
22
  from meshagent.cli import voicebot
21
- from meshagent.cli import tty
23
+ from meshagent.cli.exec import register as register_exec
22
24
 
23
25
  from meshagent.cli import otel
24
26
 
@@ -38,7 +40,7 @@ otel.init(level=logging.INFO)
38
40
  logging.getLogger("openai").setLevel(logging.ERROR)
39
41
  logging.getLogger("httpx").setLevel(logging.ERROR)
40
42
 
41
- app = typer.Typer()
43
+ app = async_typer.AsyncTyper()
42
44
  app.add_typer(call.app, name="call")
43
45
  app.add_typer(auth.app, name="auth")
44
46
  app.add_typer(projects.app, name="project")
@@ -55,7 +57,8 @@ app.add_typer(cli_secrets.app, name="secret")
55
57
  app.add_typer(cli_mcp.app, name="mcp")
56
58
  app.add_typer(chatbot.app, name="chatbot")
57
59
  app.add_typer(voicebot.app, name="voicebot")
58
- app.add_typer(tty.app, name="tty")
60
+
61
+ register_exec(app)
59
62
 
60
63
 
61
64
  def _run_async(coro):
meshagent/cli/exec.py ADDED
@@ -0,0 +1,286 @@
1
+ import sys
2
+ import tty as _tty
3
+ import termios
4
+ from meshagent.api.websocket_protocol import WebSocketClientProtocol
5
+ from meshagent.api import RoomClient
6
+ from meshagent.api.helpers import websocket_room_url
7
+ from typing import Annotated, Optional
8
+ import asyncio
9
+ import typer
10
+ from rich import print
11
+ import aiohttp
12
+ import struct
13
+ import signal
14
+ import shutil
15
+ import json
16
+ from urllib.parse import quote
17
+
18
+ from meshagent.api import ParticipantToken
19
+
20
+ import logging
21
+
22
+ from meshagent.cli.helper import (
23
+ get_client,
24
+ resolve_project_id,
25
+ resolve_api_key,
26
+ )
27
+
28
+
29
+ def register(app: typer.Typer):
30
+ @app.async_command("exec")
31
+ async def exec_command(
32
+ *,
33
+ project_id: str = None,
34
+ room: Annotated[str, typer.Option()],
35
+ name: Annotated[Optional[str], typer.Option()] = None,
36
+ image: Annotated[Optional[str], typer.Option()] = None,
37
+ api_key_id: Annotated[Optional[str], typer.Option()] = None,
38
+ command: Annotated[list[str], typer.Argument(...)] = None,
39
+ tty: bool = False,
40
+ ):
41
+ """Open an interactive websocket‑based TTY."""
42
+ client = await get_client()
43
+ try:
44
+ project_id = await resolve_project_id(project_id=project_id)
45
+ api_key_id = await resolve_api_key(
46
+ project_id=project_id, api_key_id=api_key_id
47
+ )
48
+
49
+ token = ParticipantToken(
50
+ name="tty", project_id=project_id, api_key_id=api_key_id
51
+ )
52
+
53
+ key = (
54
+ await client.decrypt_project_api_key(
55
+ project_id=project_id, id=api_key_id
56
+ )
57
+ )["token"]
58
+
59
+ token.add_role_grant(role="user")
60
+ token.add_room_grant(room)
61
+
62
+ ws_url = (
63
+ websocket_room_url(room_name=room)
64
+ + f"/exec?token={token.to_jwt(token=key)}"
65
+ )
66
+
67
+ if image:
68
+ ws_url += f"&image={quote(' '.join(image))}"
69
+
70
+ if name:
71
+ ws_url += f"&name={quote(' '.join(name))}"
72
+
73
+ if command and len(command) != 0:
74
+ ws_url += f"&command={quote(' '.join(command))}"
75
+
76
+ if tty:
77
+ if not sys.stdin.isatty():
78
+ print("[red]TTY requested but process is not a TTY[/red]")
79
+ raise typer.Exit(1)
80
+
81
+ ws_url += "&tty=true"
82
+
83
+ else:
84
+ if command is None:
85
+ print("[red]TTY required when not executing a command[/red]")
86
+ raise typer.Exit(1)
87
+
88
+ ws_url += "&tty=false"
89
+
90
+ if tty:
91
+ # Save current terminal settings so we can restore them later.
92
+ old_tty_settings = termios.tcgetattr(sys.stdin)
93
+ _tty.setraw(sys.stdin)
94
+
95
+ async with RoomClient(
96
+ protocol=WebSocketClientProtocol(
97
+ url=websocket_room_url(room_name=room),
98
+ token=token.to_jwt(token=key),
99
+ )
100
+ ):
101
+ try:
102
+ async with aiohttp.ClientSession() as session:
103
+ async with session.ws_connect(ws_url) as websocket:
104
+ send_queue = asyncio.Queue[bytes]()
105
+
106
+ loop = asyncio.get_running_loop()
107
+ (
108
+ stdout_transport,
109
+ stdout_protocol,
110
+ ) = await loop.connect_write_pipe(
111
+ asyncio.streams.FlowControlMixin, sys.stdout
112
+ )
113
+ stdout_writer = asyncio.StreamWriter(
114
+ stdout_transport, stdout_protocol, None, loop
115
+ )
116
+
117
+ (
118
+ stderr_transport,
119
+ stderr_protocol,
120
+ ) = await loop.connect_write_pipe(
121
+ asyncio.streams.FlowControlMixin, sys.stderr
122
+ )
123
+ stderr_writer = asyncio.StreamWriter(
124
+ stderr_transport, stderr_protocol, None, loop
125
+ )
126
+
127
+ async def recv_from_websocket():
128
+ while True:
129
+ done, pending = await asyncio.wait(
130
+ [asyncio.create_task(websocket.receive())],
131
+ return_when=asyncio.FIRST_COMPLETED,
132
+ )
133
+
134
+ first = done.pop()
135
+
136
+ if first == read_stdin_task:
137
+ break
138
+
139
+ message = first.result()
140
+
141
+ if websocket.closed:
142
+ break
143
+
144
+ if message.type == aiohttp.WSMsgType.CLOSE:
145
+ break
146
+
147
+ elif message.type == aiohttp.WSMsgType.CLOSING:
148
+ pass
149
+
150
+ elif message.type == aiohttp.WSMsgType.ERROR:
151
+ break
152
+
153
+ if not message.data:
154
+ break
155
+
156
+ data: bytes = message.data
157
+ if len(data) > 0:
158
+ if data[0] == 1:
159
+ stderr_writer.write(data)
160
+ await stderr_writer.drain()
161
+ elif data[0] == 0:
162
+ stdout_writer.write(data)
163
+ await stdout_writer.drain()
164
+ else:
165
+ raise ValueError(
166
+ f"Invalid channel received {data[0]}"
167
+ )
168
+
169
+ last_size = None
170
+
171
+ async def send_resize(rows, cols):
172
+ nonlocal last_size
173
+
174
+ size = (cols, rows)
175
+ if size == last_size:
176
+ return
177
+
178
+ last_size = size
179
+
180
+ resize_json = json.dumps(
181
+ {"Width": cols, "Height": rows}
182
+ ).encode("utf-8")
183
+ payload = struct.pack("B", 4) + resize_json
184
+ send_queue.put_nowait(payload)
185
+ await asyncio.sleep(5)
186
+
187
+ cols, rows = shutil.get_terminal_size(fallback=(24, 80))
188
+ if tty:
189
+ await send_resize(rows, cols)
190
+
191
+ def on_sigwinch():
192
+ cols, rows = shutil.get_terminal_size(fallback=(24, 80))
193
+ task = asyncio.create_task(send_resize(rows, cols))
194
+
195
+ def on_done(t: asyncio.Task):
196
+ t.result()
197
+
198
+ task.add_done_callback(on_done)
199
+
200
+ loop.add_signal_handler(signal.SIGWINCH, on_sigwinch)
201
+
202
+ async def read_stdin():
203
+ loop = asyncio.get_running_loop()
204
+
205
+ reader = asyncio.StreamReader()
206
+ protocol = asyncio.StreamReaderProtocol(reader)
207
+ await loop.connect_read_pipe(
208
+ lambda: protocol, sys.stdin
209
+ )
210
+
211
+ while True:
212
+ # Read one character at a time from stdin without blocking the event loop.
213
+ done, pending = await asyncio.wait(
214
+ [
215
+ asyncio.create_task(reader.read(1)),
216
+ websocket_recv_task,
217
+ ],
218
+ return_when=asyncio.FIRST_COMPLETED,
219
+ )
220
+
221
+ first = done.pop()
222
+ if first == websocket_recv_task:
223
+ break
224
+
225
+ data = first.result()
226
+ if not data:
227
+ break
228
+
229
+ if websocket.closed:
230
+ break
231
+
232
+ if tty:
233
+ if data == b"\x04":
234
+ break
235
+
236
+ if data:
237
+ send_queue.put_nowait(b"\0" + data)
238
+ else:
239
+ break
240
+
241
+ send_queue.put_nowait(b"\0")
242
+
243
+ websocket_recv_task = asyncio.create_task(
244
+ recv_from_websocket()
245
+ )
246
+ read_stdin_task = asyncio.create_task(read_stdin())
247
+
248
+ async def send_to_websocket():
249
+ while True:
250
+ try:
251
+ data = await send_queue.get()
252
+ if websocket.closed:
253
+ break
254
+
255
+ if data is not None:
256
+ await websocket.send_bytes(data)
257
+
258
+ else:
259
+ break
260
+ except asyncio.QueueShutDown:
261
+ break
262
+
263
+ send_to_websocket_task = asyncio.create_task(
264
+ send_to_websocket()
265
+ )
266
+ await asyncio.gather(
267
+ websocket_recv_task,
268
+ read_stdin_task,
269
+ )
270
+
271
+ send_queue.shutdown()
272
+ await send_to_websocket_task
273
+
274
+ finally:
275
+ if not sys.stdin.closed and tty:
276
+ # Restore original terminal settings even if the coroutine is cancelled.
277
+ termios.tcsetattr(
278
+ sys.stdin, termios.TCSADRAIN, old_tty_settings
279
+ )
280
+
281
+ except Exception as e:
282
+ print(f"[red]{e}[/red]")
283
+ logging.error("failed", exc_info=e)
284
+ raise typer.Exit(1)
285
+ finally:
286
+ await client.close()
meshagent/cli/services.py CHANGED
@@ -10,6 +10,7 @@ from pydantic import PositiveInt
10
10
  import pydantic
11
11
  from typing import Literal
12
12
  from meshagent.cli import async_typer
13
+ from pydantic import BaseModel
13
14
  from meshagent.cli.helper import (
14
15
  get_client,
15
16
  print_json_table,
@@ -24,8 +25,11 @@ from meshagent.api import (
24
25
  meshagent_base_url,
25
26
  )
26
27
 
28
+ from pydantic_yaml import parse_yaml_raw_as
29
+
27
30
  # Pydantic basemodels
28
- from meshagent.api.accounts_client import Service, Port, Services
31
+ from meshagent.api.accounts_client import Service, Port, Services, Endpoint
32
+
29
33
 
30
34
  app = async_typer.AsyncTyper()
31
35
 
@@ -88,9 +92,17 @@ def _parse_port_spec(spec: str) -> PortSpec:
88
92
  async def service_create(
89
93
  *,
90
94
  project_id: str = None,
91
- name: Annotated[str, typer.Option(help="Friendly service name")],
92
- image: Annotated[str, typer.Option(help="Container image reference")],
93
- role: Annotated[str, typer.Option(help="Service role (agent|tool)")] = None,
95
+ file: Annotated[
96
+ Optional[str],
97
+ typer.Option("--file", "-f", help="File path to a service definition"),
98
+ ] = None,
99
+ name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
100
+ image: Annotated[
101
+ Optional[str], typer.Option(help="Container image reference")
102
+ ] = None,
103
+ role: Annotated[
104
+ Optional[str], typer.Option(help="Service role (agent|tool)")
105
+ ] = None,
94
106
  pull_secret: Annotated[
95
107
  Optional[str],
96
108
  typer.Option("--pull-secret", help="Secret ID for registry"),
@@ -106,6 +118,13 @@ async def service_create(
106
118
  Optional[str],
107
119
  typer.Option("--mount", help="Path inside container to mount room storage"),
108
120
  ] = None,
121
+ room_storage_subpath: Annotated[
122
+ Optional[str],
123
+ typer.Option(
124
+ "--mount-subpath",
125
+ help="Restrict the container's mount to a subpath within the room storage",
126
+ ),
127
+ ] = None,
109
128
  port: Annotated[
110
129
  List[str],
111
130
  typer.Option(
@@ -116,40 +135,50 @@ async def service_create(
116
135
  ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
117
136
  ),
118
137
  ),
119
- ] = ...,
138
+ ] = [],
120
139
  ):
121
140
  """Create a service attached to the project."""
122
141
  client = await get_client()
123
142
  try:
124
143
  project_id = await resolve_project_id(project_id)
125
144
 
126
- # validate / coerce port specs
127
- port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
145
+ if file is not None:
146
+ with open(file, "rb") as f:
147
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
148
+ if spec.id is not None:
149
+ print("[red]id cannot be set when creating a service[/red]")
150
+ raise typer.Exit(code=1)
128
151
 
129
- ports_dict = {
130
- ps.num: Port(
131
- type=ps.type,
132
- liveness_path=ps.liveness,
133
- participant_name=ps.participant_name,
134
- path=ps.path,
135
- )
136
- for ps in port_specs
137
- } or None
152
+ service_obj = spec.to_service()
138
153
 
139
- service_obj = Service(
140
- id="",
141
- created_at=datetime.now(timezone.utc).isoformat(),
142
- name=name,
143
- role=role,
144
- image=image,
145
- command=command,
146
- pull_secret=pull_secret,
147
- room_storage_path=room_storage_path,
148
- environment=_kv_to_dict(env),
149
- environment_secrets=env_secret or None,
150
- runtime_secrets=_kv_to_dict(runtime_secret),
151
- ports=ports_dict,
152
- )
154
+ else:
155
+ # ✅ validate / coerce port specs
156
+ port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
157
+
158
+ ports_dict = {
159
+ ps.num: Port(
160
+ type=ps.type,
161
+ liveness_path=ps.liveness,
162
+ participant_name=ps.participant_name,
163
+ path=ps.path,
164
+ )
165
+ for ps in port_specs
166
+ } or None
167
+
168
+ service_obj = Service(
169
+ created_at=datetime.now(timezone.utc).isoformat(),
170
+ name=name,
171
+ role=role,
172
+ image=image,
173
+ command=command,
174
+ pull_secret=pull_secret,
175
+ room_storage_path=room_storage_path,
176
+ room_storage_subpath=room_storage_subpath,
177
+ environment=_kv_to_dict(env),
178
+ environment_secrets=env_secret or None,
179
+ runtime_secrets=_kv_to_dict(runtime_secret),
180
+ ports=ports_dict,
181
+ )
153
182
 
154
183
  try:
155
184
  new_id = (
@@ -157,7 +186,7 @@ async def service_create(
157
186
  )["id"]
158
187
  except ClientResponseError as exc:
159
188
  if exc.status == 409:
160
- print(f"[red]Service name already in use: {name}[/red]")
189
+ print(f"[red]Service name already in use: {service_obj.name}[/red]")
161
190
  raise typer.Exit(code=1)
162
191
  raise
163
192
  else:
@@ -167,20 +196,221 @@ async def service_create(
167
196
  await client.close()
168
197
 
169
198
 
199
+ @app.async_command("update")
200
+ async def service_update(
201
+ *,
202
+ project_id: str = None,
203
+ id: Optional[str] = None,
204
+ file: Annotated[
205
+ Optional[str],
206
+ typer.Option("--file", "-f", help="File path to a service definition"),
207
+ ] = None,
208
+ name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
209
+ image: Annotated[
210
+ Optional[str], typer.Option(help="Container image reference")
211
+ ] = None,
212
+ role: Annotated[
213
+ Optional[str], typer.Option(help="Service role (agent|tool)")
214
+ ] = None,
215
+ pull_secret: Annotated[
216
+ Optional[str],
217
+ typer.Option("--pull-secret", help="Secret ID for registry"),
218
+ ] = None,
219
+ command: Annotated[
220
+ Optional[str],
221
+ typer.Option("--command", help="Override ENTRYPOINT/CMD"),
222
+ ] = None,
223
+ env: Annotated[List[str], typer.Option("--env", "-e", help="KEY=VALUE")] = [],
224
+ env_secret: Annotated[List[str], typer.Option("--env-secret")] = [],
225
+ runtime_secret: Annotated[List[str], typer.Option("--runtime-secret")] = [],
226
+ room_storage_path: Annotated[
227
+ Optional[str],
228
+ typer.Option("--mount", help="Path inside container to mount room storage"),
229
+ ] = None,
230
+ room_storage_subpath: Annotated[
231
+ Optional[str],
232
+ typer.Option(
233
+ "--mount-subpath",
234
+ help="Restrict the container's mount to a subpath within the room storage",
235
+ ),
236
+ ] = None,
237
+ port: Annotated[
238
+ List[str],
239
+ typer.Option(
240
+ "--port",
241
+ "-p",
242
+ help=(
243
+ "Repeatable. Example:\n"
244
+ ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
245
+ ),
246
+ ),
247
+ ] = [],
248
+ create: Annotated[
249
+ Optional[bool],
250
+ typer.Option(
251
+ help="create the service if it does not exist",
252
+ ),
253
+ ] = False,
254
+ ):
255
+ """Create a service attached to the project."""
256
+ client = await get_client()
257
+ try:
258
+ project_id = await resolve_project_id(project_id)
259
+
260
+ if file is not None:
261
+ with open(file, "rb") as f:
262
+ spec = parse_yaml_raw_as(ServiceSpec, f.read())
263
+ if spec.id is not None:
264
+ id = spec.id
265
+ service_obj = spec.to_service()
266
+
267
+ else:
268
+ # ✅ validate / coerce port specs
269
+ port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
270
+
271
+ ports_dict = {
272
+ ps.num: Port(
273
+ type=ps.type,
274
+ liveness_path=ps.liveness,
275
+ participant_name=ps.participant_name,
276
+ path=ps.path,
277
+ )
278
+ for ps in port_specs
279
+ } or None
280
+
281
+ service_obj = Service(
282
+ created_at=datetime.now(timezone.utc).isoformat(),
283
+ name=name,
284
+ role=role,
285
+ image=image,
286
+ command=command,
287
+ pull_secret=pull_secret,
288
+ room_storage_path=room_storage_path,
289
+ room_storage_subpath=room_storage_subpath,
290
+ environment=_kv_to_dict(env),
291
+ environment_secrets=env_secret or None,
292
+ runtime_secrets=_kv_to_dict(runtime_secret),
293
+ ports=ports_dict,
294
+ )
295
+
296
+ try:
297
+ if id is None:
298
+ services = await client.list_services(project_id=project_id)
299
+ for s in services:
300
+ if s.name == service_obj.name:
301
+ id = s.id
302
+
303
+ if id is None and not create:
304
+ print("[red]pass a service id or specify --create[/red]")
305
+ raise typer.Exit(code=1)
306
+
307
+ if id is None:
308
+ id = (
309
+ await client.create_service(
310
+ project_id=project_id, service=service_obj
311
+ )
312
+ )["id"]
313
+
314
+ else:
315
+ await client.update_service(
316
+ project_id=project_id, service_id=id, service=service_obj
317
+ )
318
+
319
+ except ClientResponseError as exc:
320
+ if exc.status == 409:
321
+ print(f"[red]Service name already in use: {service_obj.name}[/red]")
322
+ raise typer.Exit(code=1)
323
+ raise
324
+ else:
325
+ print(f"[green]Updated service:[/] {id}")
326
+
327
+ finally:
328
+ await client.close()
329
+
330
+
331
+ class ServicePortEndpointSpec(pydantic.BaseModel):
332
+ path: str
333
+ identity: str
334
+ type: Optional[Literal["mcp.sse", "meshagent.callable", "http", "tcp"]] = None
335
+
336
+
337
+ class ServicePortSpec(pydantic.BaseModel):
338
+ num: PositiveInt
339
+ type: Literal["mcp.sse", "meshagent.callable", "http", "tcp"]
340
+ endpoints: list[ServicePortEndpointSpec] = []
341
+ liveness: Optional[str] = None
342
+
343
+
344
+ class ServiceSpec(BaseModel):
345
+ version: Literal["v1"]
346
+ kind: Literal["Service"]
347
+ id: Optional[str] = None
348
+ name: str
349
+ command: Optional[str] = None
350
+ image: str
351
+ ports: Optional[list[ServicePortSpec]] = []
352
+ role: Optional[Literal["user", "tool", "agent"]] = None
353
+ environment: Optional[dict[str, str]] = {}
354
+ secrets: list[str] = []
355
+ pull_secret: Optional[str] = None
356
+ room_storage_path: Optional[str] = None
357
+ room_storage_subpath: Optional[str] = None
358
+
359
+ def to_service(self):
360
+ ports = {}
361
+ for p in self.ports:
362
+ port = Port(liveness_path=p.liveness, type=p.type, endpoints=[])
363
+ for endpoint in p.endpoints:
364
+ type = port.type
365
+ if endpoint.type is not None:
366
+ type = endpoint.type
367
+
368
+ port.endpoints.append(
369
+ Endpoint(
370
+ type=type,
371
+ participant_name=endpoint.identity,
372
+ path=endpoint.path,
373
+ )
374
+ )
375
+ ports[p.num] = port
376
+ return Service(
377
+ id="",
378
+ created_at=datetime.now(timezone.utc).isoformat(),
379
+ name=self.name,
380
+ command=self.command,
381
+ image=self.image,
382
+ ports=ports,
383
+ role=self.role,
384
+ environment=self.environment,
385
+ environment_secrets=self.secrets,
386
+ pull_secret=self.pull_secret,
387
+ room_storage_path=self.room_storage_path,
388
+ room_storage_subpath=self.room_storage_subpath,
389
+ )
390
+
391
+
170
392
  @app.async_command("test")
171
393
  async def service_test(
172
394
  *,
173
395
  project_id: str = None,
174
396
  api_key_id: Annotated[Optional[str], typer.Option()] = None,
397
+ file: Annotated[
398
+ Optional[str],
399
+ typer.Option("--file", "-f", help="File path to a service definition"),
400
+ ],
175
401
  room: Annotated[
176
- str,
402
+ Optional[str],
177
403
  typer.Option(
178
404
  help="A room name to test the service in (must not be currently running)"
179
405
  ),
180
- ],
181
- name: Annotated[str, typer.Option(help="Friendly service name")],
182
- role: Annotated[str, typer.Option(help="Service role (agent|tool)")] = None,
183
- image: Annotated[str, typer.Option(help="Container image reference")],
406
+ ] = None,
407
+ name: Annotated[Optional[str], typer.Option(help="Friendly service name")] = None,
408
+ role: Annotated[
409
+ Optional[str], typer.Option(help="Service role (agent|tool)")
410
+ ] = None,
411
+ image: Annotated[
412
+ Optional[str], typer.Option(help="Container image reference")
413
+ ] = None,
184
414
  pull_secret: Annotated[
185
415
  Optional[str],
186
416
  typer.Option("--pull-secret", help="Secret ID for registry"),
@@ -206,7 +436,7 @@ async def service_test(
206
436
  ' -p "num=8080 type=[mcp.sse | meshagent.callable | http | tcp] liveness=/health path=/agent participant_name=myname"'
207
437
  ),
208
438
  ),
209
- ] = ...,
439
+ ] = [],
210
440
  timeout: Annotated[
211
441
  Optional[int],
212
442
  typer.Option(
@@ -221,33 +451,37 @@ async def service_test(
221
451
 
222
452
  api_key_id = await resolve_api_key(project_id, api_key_id)
223
453
 
224
- # validate / coerce port specs
225
- port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
454
+ if file is not None:
455
+ with open(file, "rb") as f:
456
+ service_obj = parse_yaml_raw_as(ServiceSpec, f.read()).to_service()
226
457
 
227
- ports_dict = {
228
- ps.num: Port(
229
- type=ps.type,
230
- liveness_path=ps.liveness,
231
- participant_name=ps.participant_name,
232
- path=ps.path,
458
+ else:
459
+ # ✅ validate / coerce port specs
460
+ port_specs: List[PortSpec] = [_parse_port_spec(s) for s in port]
461
+
462
+ ports_dict = {
463
+ str(ps.num): Port(
464
+ type=ps.type,
465
+ liveness_path=ps.liveness,
466
+ participant_name=ps.participant_name,
467
+ path=ps.path,
468
+ )
469
+ for ps in port_specs
470
+ } or None
471
+
472
+ service_obj = Service(
473
+ created_at=datetime.now(timezone.utc).isoformat(),
474
+ role=role,
475
+ name=name,
476
+ image=image,
477
+ command=command,
478
+ pull_secret=pull_secret,
479
+ room_storage_path=room_storage_path,
480
+ environment=_kv_to_dict(env),
481
+ environment_secrets=env_secret or None,
482
+ runtime_secrets=_kv_to_dict(runtime_secret),
483
+ ports=ports_dict,
233
484
  )
234
- for ps in port_specs
235
- } or None
236
-
237
- service_obj = Service(
238
- id="",
239
- created_at=datetime.now(timezone.utc).isoformat(),
240
- role=role,
241
- name=name,
242
- image=image,
243
- command=command,
244
- pull_secret=pull_secret,
245
- room_storage_path=room_storage_path,
246
- environment=_kv_to_dict(env),
247
- environment_secrets=env_secret or None,
248
- runtime_secrets=_kv_to_dict(runtime_secret),
249
- ports=ports_dict,
250
- )
251
485
 
252
486
  try:
253
487
  token = ParticipantToken(
meshagent/cli/storage.py CHANGED
@@ -112,7 +112,7 @@ async def storage_cp_command(
112
112
  ] = None,
113
113
  name: Annotated[
114
114
  str, typer.Option(..., help="Participant name (if copying to/from remote)")
115
- ],
115
+ ] = "cli",
116
116
  role: str = "user",
117
117
  source_path: str,
118
118
  dest_path: str,
meshagent/cli/version.py CHANGED
@@ -1 +1 @@
1
- __version__ = "0.0.39"
1
+ __version__ = "0.2.0"
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: meshagent-cli
3
- Version: 0.0.39
3
+ Version: 0.2.0
4
4
  Summary: CLI for Meshagent
5
5
  License-Expression: Apache-2.0
6
6
  Project-URL: Documentation, https://docs.meshagent.com
@@ -10,15 +10,18 @@ Requires-Python: >=3.12
10
10
  Description-Content-Type: text/markdown
11
11
  Requires-Dist: typer~=0.15
12
12
  Requires-Dist: pydantic-yaml~=1.4
13
- Requires-Dist: meshagent-api~=0.0.39
14
- Requires-Dist: meshagent-agents~=0.0.39
15
- Requires-Dist: meshagent-tools~=0.0.39
16
- Requires-Dist: meshagent-mcp~=0.0.39
13
+ Requires-Dist: meshagent-api~=0.2.0
14
+ Requires-Dist: meshagent-agents~=0.2.0
15
+ Requires-Dist: meshagent-computers~=0.2.0
16
+ Requires-Dist: meshagent-openai~=0.2.0
17
+ Requires-Dist: meshagent-tools~=0.2.0
18
+ Requires-Dist: meshagent-mcp~=0.2.0
17
19
  Requires-Dist: supabase~=2.15
18
20
  Requires-Dist: fastmcp~=2.8
19
21
  Requires-Dist: opentelemetry-distro~=0.54b1
20
22
  Requires-Dist: opentelemetry-exporter-otlp-proto-http~=1.33
21
23
  Requires-Dist: art~=6.5
24
+ Requires-Dist: pydantic-yaml~=1.5
22
25
 
23
26
  ## MeshAgent CLI
24
27
 
@@ -4,26 +4,26 @@ meshagent/cli/api_keys.py,sha256=4mBqyh_WAogaMIYhRBQOCjfsSSp1RY9BqiI_6DaGLmk,496
4
4
  meshagent/cli/async_typer.py,sha256=GCeSefBDbpd-V4V8LrvHGUTBMth3HspVMfFa-HUZ0cg,898
5
5
  meshagent/cli/auth.py,sha256=tPipbOtHnsrvJ-OUOE-lyfvvIhkTIGYlkgS81hLdyB8,783
6
6
  meshagent/cli/auth_async.py,sha256=mi2-u949M412PAMN-PCdHEiF9hGf0W3m1RAseZX07-w,4058
7
- meshagent/cli/call.py,sha256=CMPMHbMtsXqFbekuo2ZgupSzXvnKrODBaf2bZugW0yc,4646
7
+ meshagent/cli/call.py,sha256=aVRs-7QDZWxK8jYc71oACNIkHuoDe1KUyfl8EtUbj-0,5306
8
8
  meshagent/cli/chatbot.py,sha256=pK-_G3CiMbV-NC0iXzbjztu4f-ZAeXNzcmOB-HxJWQo,7928
9
- meshagent/cli/cli.py,sha256=D2cIVAgYZ095XqWBrvd7Qq60yGL7pfLTIK4Y52xrKSM,6314
9
+ meshagent/cli/cli.py,sha256=Xltp1pICOdWEoCexeZz1taHPcRhRv7dM2e7tktyrd2k,6376
10
10
  meshagent/cli/cli_mcp.py,sha256=BTTAMn3u4i1uTDItFxmxMTsSAvFaWtdI3YdZngHz11g,10641
11
11
  meshagent/cli/cli_secrets.py,sha256=lkfR8tVjOA5KdynAhKCg5Z2EJkgrHFTX43pdB60YiU4,13767
12
12
  meshagent/cli/developer.py,sha256=JWz-qcduCbFopQ1DNGbwrknzFt59KBLIXx8DyD6PxZM,3081
13
+ meshagent/cli/exec.py,sha256=46OyTVr3ejfDuNjBKkV9vkk62O808isD7bEC6jUgQHc,11338
13
14
  meshagent/cli/helper.py,sha256=gbd6Tvlp7CObPp3srWm-mbAe7tBD0iacg6PitGWJdtw,4874
14
15
  meshagent/cli/messaging.py,sha256=cr-oVAu_s8uEPUm3GELSq8yaVDnEWlt02D5V4KbA6wc,6437
15
16
  meshagent/cli/otel.py,sha256=1yoMGivskLV9f7M_LqCLKbttTTAPmFY5yhWXqFzvRN8,4066
16
17
  meshagent/cli/participant_token.py,sha256=N07hblRjj0zDKxl5CQXJjIMmft5s9mWgKZKz-VZyhKw,1400
17
18
  meshagent/cli/projects.py,sha256=WaO7M-D4ghy0nVpvwZ08ZEcybZ_e_cFe6rEoNxyAWkc,3306
18
- meshagent/cli/services.py,sha256=og4iOwxIT-TgYtTBExGbrABQJva9yo5PjeD_eFLw6dU,11358
19
+ meshagent/cli/services.py,sha256=1afeMH41Kj0-1uAKB72buWnPFcgaqT4xFVO4FEpTPC0,19308
19
20
  meshagent/cli/sessions.py,sha256=MP7XhrtkUrealdpl8IKrTR3u9sjF15sG175-Y_m7nps,800
20
- meshagent/cli/storage.py,sha256=Q3ajuC6j4GLlU4jZoUW_Zsl1dOr04vzLibw2DPedJJ4,35564
21
- meshagent/cli/tty.py,sha256=GBVz5XoAKTCoczMnnJrLLmQiAHTvHE6HYxxSYfw-UZc,3988
22
- meshagent/cli/version.py,sha256=rFnu2wT8xSk2KNzRssUju1l870Gz9DdJMfBB8jhzzO0,23
21
+ meshagent/cli/storage.py,sha256=wtLKYFyfsErCePxCG962Xb--moCN-zj9uVCtRPhIpfA,35572
22
+ meshagent/cli/version.py,sha256=Zn1KFblwuFHiDRdRAiRnDBRkbPttWh44jKa5zG2ov0E,22
23
23
  meshagent/cli/voicebot.py,sha256=Ykn9mUhcwl03ZCPDRua6moeNyrvToGPowQfjhPM0zqA,5032
24
24
  meshagent/cli/webhook.py,sha256=r5zct-UBQYSq3BWmnZRrHVOEHVlkY0j8uDxGVn3Pbxo,2902
25
- meshagent_cli-0.0.39.dist-info/METADATA,sha256=_sxxWGR7Cs7cYU_dIPCNOiy0hnIzN4abrkT2b8YkPNA,1338
26
- meshagent_cli-0.0.39.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
- meshagent_cli-0.0.39.dist-info/entry_points.txt,sha256=WRcGGN4vMtvC5Pgl3uRFqsJiQXNoHuLLa-TCSY3gAhQ,52
28
- meshagent_cli-0.0.39.dist-info/top_level.txt,sha256=GlcXnHtRP6m7zlG3Df04M35OsHtNXy_DY09oFwWrH74,10
29
- meshagent_cli-0.0.39.dist-info/RECORD,,
25
+ meshagent_cli-0.2.0.dist-info/METADATA,sha256=vGR8j-Ajp6FtL4PXzzGfh_0-mMN17HOuXO6QM_dQLj8,1448
26
+ meshagent_cli-0.2.0.dist-info/WHEEL,sha256=_zCd3N1l69ArxyTb8rzEoP9TpbYXkqRFSNOD5OuxnTs,91
27
+ meshagent_cli-0.2.0.dist-info/entry_points.txt,sha256=WRcGGN4vMtvC5Pgl3uRFqsJiQXNoHuLLa-TCSY3gAhQ,52
28
+ meshagent_cli-0.2.0.dist-info/top_level.txt,sha256=GlcXnHtRP6m7zlG3Df04M35OsHtNXy_DY09oFwWrH74,10
29
+ meshagent_cli-0.2.0.dist-info/RECORD,,
meshagent/cli/tty.py DELETED
@@ -1,118 +0,0 @@
1
- import sys
2
- import tty
3
- import termios
4
- from meshagent.api.helpers import websocket_room_url
5
- from typing import Annotated, Optional
6
-
7
- import asyncio
8
- import typer
9
- from rich import print
10
- import aiohttp
11
-
12
- from meshagent.api import ParticipantToken
13
- from meshagent.cli import async_typer
14
- from meshagent.cli.helper import (
15
- get_client,
16
- resolve_project_id,
17
- resolve_api_key,
18
- )
19
-
20
-
21
- app = async_typer.AsyncTyper()
22
-
23
-
24
- @app.async_command("connect")
25
- async def tty_command(
26
- *,
27
- project_id: str = None,
28
- room: Annotated[str, typer.Option()],
29
- api_key_id: Annotated[Optional[str], typer.Option()] = None,
30
- ):
31
- """Open an interactive websocket‑based TTY."""
32
- client = await get_client()
33
- try:
34
- project_id = await resolve_project_id(project_id=project_id)
35
- api_key_id = await resolve_api_key(project_id=project_id, api_key_id=api_key_id)
36
-
37
- token = ParticipantToken(
38
- name="tty", project_id=project_id, api_key_id=api_key_id
39
- )
40
-
41
- key = (
42
- await client.decrypt_project_api_key(project_id=project_id, id=api_key_id)
43
- )["token"]
44
-
45
- token.add_role_grant(role="user")
46
- token.add_room_grant(room)
47
-
48
- ws_url = (
49
- websocket_room_url(room_name=room) + f"/tty?token={token.to_jwt(token=key)}"
50
- )
51
-
52
- print(f"[bold green]Connecting to[/bold green] {room}")
53
-
54
- # Save current terminal settings so we can restore them later.
55
- old_tty_settings = termios.tcgetattr(sys.stdin)
56
- try:
57
- async with aiohttp.ClientSession() as session:
58
- async with session.ws_connect(ws_url) as websocket:
59
- print(f"[bold green]Connected to[/bold green] {room}")
60
-
61
- tty.setraw(sys.stdin)
62
-
63
- async def recv_from_websocket():
64
- async for message in websocket:
65
- if message.type == aiohttp.WSMsgType.CLOSE:
66
- await websocket.close()
67
-
68
- elif message.type == aiohttp.WSMsgType.ERROR:
69
- await websocket.close()
70
-
71
- data: bytes = message.data
72
- sys.stdout.write(data.decode("utf-8"))
73
- sys.stdout.flush()
74
-
75
- async def send_to_websocket():
76
- loop = asyncio.get_running_loop()
77
-
78
- reader = asyncio.StreamReader()
79
- protocol = asyncio.StreamReaderProtocol(reader)
80
- await loop.connect_read_pipe(lambda: protocol, sys.stdin)
81
-
82
- while True:
83
- # Read one character at a time from stdin without blocking the event loop.
84
-
85
- data = await reader.read(1)
86
- if not data:
87
- break
88
-
89
- if websocket.closed:
90
- break
91
-
92
- if data == b"\x03":
93
- print("<CTRL-C>\n")
94
- break
95
-
96
- if data:
97
- await websocket.send_bytes(data)
98
- else:
99
- await websocket.close(code=1000)
100
- break
101
-
102
- done, pending = await asyncio.wait(
103
- [
104
- asyncio.create_task(recv_from_websocket()),
105
- asyncio.create_task(send_to_websocket()),
106
- ],
107
- return_when=asyncio.FIRST_COMPLETED,
108
- )
109
-
110
- for task in pending:
111
- task.cancel()
112
-
113
- finally:
114
- # Restore original terminal settings even if the coroutine is cancelled.
115
- termios.tcsetattr(sys.stdin, termios.TCSADRAIN, old_tty_settings)
116
-
117
- finally:
118
- await client.close()