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.
- meshagent/cli/__init__.py +3 -0
- meshagent/cli/agent.py +273 -0
- meshagent/cli/api_keys.py +102 -0
- meshagent/cli/async_typer.py +79 -0
- meshagent/cli/auth.py +30 -0
- meshagent/cli/auth_async.py +295 -0
- meshagent/cli/call.py +215 -0
- meshagent/cli/chatbot.py +1983 -0
- meshagent/cli/cli.py +187 -0
- meshagent/cli/cli_mcp.py +408 -0
- meshagent/cli/cli_secrets.py +414 -0
- meshagent/cli/common_options.py +47 -0
- meshagent/cli/containers.py +725 -0
- meshagent/cli/database.py +997 -0
- meshagent/cli/developer.py +70 -0
- meshagent/cli/exec.py +397 -0
- meshagent/cli/helper.py +236 -0
- meshagent/cli/helpers.py +185 -0
- meshagent/cli/host.py +41 -0
- meshagent/cli/mailbot.py +1295 -0
- meshagent/cli/mailboxes.py +223 -0
- meshagent/cli/meeting_transcriber.py +138 -0
- meshagent/cli/messaging.py +157 -0
- meshagent/cli/multi.py +357 -0
- meshagent/cli/oauth2.py +341 -0
- meshagent/cli/participant_token.py +63 -0
- meshagent/cli/port.py +70 -0
- meshagent/cli/projects.py +105 -0
- meshagent/cli/queue.py +91 -0
- meshagent/cli/room.py +26 -0
- meshagent/cli/rooms.py +214 -0
- meshagent/cli/services.py +722 -0
- meshagent/cli/sessions.py +26 -0
- meshagent/cli/storage.py +813 -0
- meshagent/cli/sync.py +434 -0
- meshagent/cli/task_runner.py +1317 -0
- meshagent/cli/version.py +1 -0
- meshagent/cli/voicebot.py +624 -0
- meshagent/cli/webhook.py +100 -0
- meshagent/cli/worker.py +1403 -0
- meshagent_cli-0.22.2.dist-info/METADATA +49 -0
- meshagent_cli-0.22.2.dist-info/RECORD +45 -0
- meshagent_cli-0.22.2.dist-info/WHEEL +5 -0
- meshagent_cli-0.22.2.dist-info/entry_points.txt +2 -0
- meshagent_cli-0.22.2.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import json
|
|
3
|
+
from rich import print
|
|
4
|
+
from meshagent.cli.common_options import ProjectIdOption, RoomOption
|
|
5
|
+
from meshagent.cli import async_typer
|
|
6
|
+
from meshagent.cli.helper import (
|
|
7
|
+
get_client,
|
|
8
|
+
resolve_project_id,
|
|
9
|
+
resolve_room,
|
|
10
|
+
)
|
|
11
|
+
from meshagent.api import (
|
|
12
|
+
RoomClient,
|
|
13
|
+
WebSocketClientProtocol,
|
|
14
|
+
)
|
|
15
|
+
from meshagent.api.helpers import meshagent_base_url, websocket_room_url
|
|
16
|
+
|
|
17
|
+
app = async_typer.AsyncTyper(help="Developer utilities for a room")
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
@app.async_command("watch", help="Stream developer logs from a room")
|
|
21
|
+
async def watch_logs(
|
|
22
|
+
*,
|
|
23
|
+
project_id: ProjectIdOption,
|
|
24
|
+
room: RoomOption,
|
|
25
|
+
):
|
|
26
|
+
"""
|
|
27
|
+
Watch logs from the developer feed in the specified room.
|
|
28
|
+
"""
|
|
29
|
+
|
|
30
|
+
account_client = await get_client()
|
|
31
|
+
try:
|
|
32
|
+
# Resolve project ID (or fetch from the active project if not provided)
|
|
33
|
+
project_id = await resolve_project_id(project_id=project_id)
|
|
34
|
+
room = resolve_room(room)
|
|
35
|
+
|
|
36
|
+
connection = await account_client.connect_room(project_id=project_id, room=room)
|
|
37
|
+
|
|
38
|
+
print("[bold green]Connecting to room...[/bold green]")
|
|
39
|
+
async with RoomClient(
|
|
40
|
+
protocol=WebSocketClientProtocol(
|
|
41
|
+
url=websocket_room_url(room_name=room, base_url=meshagent_base_url()),
|
|
42
|
+
token=connection.jwt,
|
|
43
|
+
)
|
|
44
|
+
) as client:
|
|
45
|
+
# Create a developer client from the room client
|
|
46
|
+
|
|
47
|
+
# Define how to handle the incoming log events
|
|
48
|
+
def handle_log(type: str, data: dict):
|
|
49
|
+
# You can customize this print to suit your needs
|
|
50
|
+
print(f"[magenta]{type}[/magenta]: {json.dumps(data, indent=2)}")
|
|
51
|
+
|
|
52
|
+
# Attach our handler to the "log" event
|
|
53
|
+
client.developer.on("log", handle_log)
|
|
54
|
+
|
|
55
|
+
# Enable watching
|
|
56
|
+
await client.developer.enable()
|
|
57
|
+
print("[bold cyan]watching enabled. Press Ctrl+C to stop.[/bold cyan]")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Block forever, until Ctrl+C
|
|
61
|
+
while True:
|
|
62
|
+
await asyncio.sleep(10)
|
|
63
|
+
except KeyboardInterrupt:
|
|
64
|
+
print("[bold red]Stopping watch...[/bold red]")
|
|
65
|
+
finally:
|
|
66
|
+
# Disable watching before exiting
|
|
67
|
+
await client.developer.disable()
|
|
68
|
+
|
|
69
|
+
finally:
|
|
70
|
+
await account_client.close()
|
meshagent/cli/exec.py
ADDED
|
@@ -0,0 +1,397 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
from meshagent.api.websocket_protocol import WebSocketClientProtocol
|
|
4
|
+
from meshagent.api import RoomClient
|
|
5
|
+
from meshagent.api.helpers import websocket_room_url
|
|
6
|
+
from typing import Annotated, Optional
|
|
7
|
+
from meshagent.cli.common_options import ProjectIdOption, RoomOption
|
|
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
|
+
import threading
|
|
18
|
+
import time
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
import logging
|
|
22
|
+
|
|
23
|
+
from meshagent.cli.helper import (
|
|
24
|
+
get_client,
|
|
25
|
+
resolve_project_id,
|
|
26
|
+
resolve_room,
|
|
27
|
+
)
|
|
28
|
+
|
|
29
|
+
if os.name == "nt":
|
|
30
|
+
import msvcrt
|
|
31
|
+
import ctypes
|
|
32
|
+
from ctypes import wintypes
|
|
33
|
+
|
|
34
|
+
_kernel32 = ctypes.windll.kernel32
|
|
35
|
+
_ENABLE_ECHO_INPUT = 0x0004
|
|
36
|
+
_ENABLE_LINE_INPUT = 0x0002
|
|
37
|
+
|
|
38
|
+
def set_raw(f):
|
|
39
|
+
"""Disable line and echo mode for the given file handle."""
|
|
40
|
+
handle = msvcrt.get_osfhandle(f.fileno())
|
|
41
|
+
original_mode = wintypes.DWORD()
|
|
42
|
+
if not _kernel32.GetConsoleMode(handle, ctypes.byref(original_mode)):
|
|
43
|
+
return None
|
|
44
|
+
new_mode = original_mode.value & ~(_ENABLE_ECHO_INPUT | _ENABLE_LINE_INPUT)
|
|
45
|
+
_kernel32.SetConsoleMode(handle, new_mode)
|
|
46
|
+
return handle, original_mode.value
|
|
47
|
+
|
|
48
|
+
def restore(f, state):
|
|
49
|
+
if state is None:
|
|
50
|
+
return None
|
|
51
|
+
handle, mode = state
|
|
52
|
+
_kernel32.SetConsoleMode(handle, mode)
|
|
53
|
+
return None
|
|
54
|
+
|
|
55
|
+
else:
|
|
56
|
+
import termios
|
|
57
|
+
import tty as _tty
|
|
58
|
+
|
|
59
|
+
def set_raw(fd):
|
|
60
|
+
old = termios.tcgetattr(fd)
|
|
61
|
+
_tty.setraw(fd)
|
|
62
|
+
return old
|
|
63
|
+
|
|
64
|
+
def restore(fd, old_settings):
|
|
65
|
+
termios.tcsetattr(fd, termios.TCSADRAIN, old_settings)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
class _StdWriter:
|
|
69
|
+
"""Simple asyncio-friendly wrapper for standard streams on Windows."""
|
|
70
|
+
|
|
71
|
+
def __init__(self, file):
|
|
72
|
+
self._file = file
|
|
73
|
+
|
|
74
|
+
def write(self, data: bytes) -> None:
|
|
75
|
+
self._file.buffer.write(data)
|
|
76
|
+
|
|
77
|
+
async def drain(self) -> None:
|
|
78
|
+
await asyncio.get_running_loop().run_in_executor(None, self._file.flush)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def register(app: typer.Typer):
|
|
82
|
+
@app.async_command("exec")
|
|
83
|
+
async def exec_command(
|
|
84
|
+
*,
|
|
85
|
+
project_id: ProjectIdOption,
|
|
86
|
+
room: RoomOption,
|
|
87
|
+
name: Annotated[
|
|
88
|
+
Optional[str], typer.Option(help="Optional exec session name")
|
|
89
|
+
] = None,
|
|
90
|
+
image: Annotated[
|
|
91
|
+
Optional[str],
|
|
92
|
+
typer.Option(help="Optional container image to use for the exec session"),
|
|
93
|
+
] = None,
|
|
94
|
+
command: Annotated[
|
|
95
|
+
list[str],
|
|
96
|
+
typer.Argument(..., help="Command to execute (omit when using `--tty`)"),
|
|
97
|
+
] = None,
|
|
98
|
+
tty: Annotated[
|
|
99
|
+
bool,
|
|
100
|
+
typer.Option(
|
|
101
|
+
"--tty/--no-tty",
|
|
102
|
+
help="Allocate an interactive TTY (requires a real terminal)",
|
|
103
|
+
),
|
|
104
|
+
] = False,
|
|
105
|
+
room_storage_path: Annotated[
|
|
106
|
+
str, typer.Option(help="Room storage mount path (default: /data)")
|
|
107
|
+
] = "/data",
|
|
108
|
+
):
|
|
109
|
+
"""Open an interactive websocket‑based TTY."""
|
|
110
|
+
client = await get_client()
|
|
111
|
+
try:
|
|
112
|
+
project_id = await resolve_project_id(project_id=project_id)
|
|
113
|
+
room = resolve_room(room)
|
|
114
|
+
|
|
115
|
+
connection = await client.connect_room(project_id=project_id, room=room)
|
|
116
|
+
|
|
117
|
+
ws_url = (
|
|
118
|
+
websocket_room_url(room_name=room) + f"/exec?token={connection.jwt}"
|
|
119
|
+
)
|
|
120
|
+
|
|
121
|
+
if image:
|
|
122
|
+
ws_url += f"&image={quote(' '.join(image))}"
|
|
123
|
+
|
|
124
|
+
if name:
|
|
125
|
+
ws_url += f"&name={quote(' '.join(name))}"
|
|
126
|
+
|
|
127
|
+
if command and len(command) != 0:
|
|
128
|
+
ws_url += f"&command={quote(' '.join(command))}"
|
|
129
|
+
|
|
130
|
+
if room_storage_path:
|
|
131
|
+
room_storage_path += (
|
|
132
|
+
f"&room_storage_path={quote(' '.join(room_storage_path))}"
|
|
133
|
+
)
|
|
134
|
+
|
|
135
|
+
if tty:
|
|
136
|
+
if not sys.stdin.isatty():
|
|
137
|
+
print("[red]TTY requested but process is not a TTY[/red]")
|
|
138
|
+
raise typer.Exit(1)
|
|
139
|
+
|
|
140
|
+
ws_url += "&tty=true"
|
|
141
|
+
|
|
142
|
+
else:
|
|
143
|
+
if command is None:
|
|
144
|
+
print("[red]TTY required when not executing a command[/red]")
|
|
145
|
+
raise typer.Exit(1)
|
|
146
|
+
|
|
147
|
+
ws_url += "&tty=false"
|
|
148
|
+
|
|
149
|
+
if tty:
|
|
150
|
+
# Save current terminal settings so we can restore them later.
|
|
151
|
+
old_tty_settings = set_raw(sys.stdin)
|
|
152
|
+
|
|
153
|
+
async with RoomClient(
|
|
154
|
+
protocol=WebSocketClientProtocol(
|
|
155
|
+
url=websocket_room_url(room_name=room),
|
|
156
|
+
token=connection.jwt,
|
|
157
|
+
)
|
|
158
|
+
):
|
|
159
|
+
try:
|
|
160
|
+
async with aiohttp.ClientSession() as session:
|
|
161
|
+
async with session.ws_connect(ws_url) as websocket:
|
|
162
|
+
send_queue = asyncio.Queue[bytes]()
|
|
163
|
+
|
|
164
|
+
loop = asyncio.get_running_loop()
|
|
165
|
+
if os.name == "nt":
|
|
166
|
+
stdout_writer = _StdWriter(sys.stdout)
|
|
167
|
+
stderr_writer = _StdWriter(sys.stderr)
|
|
168
|
+
else:
|
|
169
|
+
(
|
|
170
|
+
stdout_transport,
|
|
171
|
+
stdout_protocol,
|
|
172
|
+
) = await loop.connect_write_pipe(
|
|
173
|
+
asyncio.streams.FlowControlMixin, sys.stdout
|
|
174
|
+
)
|
|
175
|
+
stdout_writer = asyncio.StreamWriter(
|
|
176
|
+
stdout_transport, stdout_protocol, None, loop
|
|
177
|
+
)
|
|
178
|
+
|
|
179
|
+
(
|
|
180
|
+
stderr_transport,
|
|
181
|
+
stderr_protocol,
|
|
182
|
+
) = await loop.connect_write_pipe(
|
|
183
|
+
asyncio.streams.FlowControlMixin, sys.stderr
|
|
184
|
+
)
|
|
185
|
+
stderr_writer = asyncio.StreamWriter(
|
|
186
|
+
stderr_transport, stderr_protocol, None, loop
|
|
187
|
+
)
|
|
188
|
+
|
|
189
|
+
async def recv_from_websocket():
|
|
190
|
+
while True:
|
|
191
|
+
done, pending = await asyncio.wait(
|
|
192
|
+
[asyncio.create_task(websocket.receive())],
|
|
193
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
first = done.pop()
|
|
197
|
+
|
|
198
|
+
if first == read_stdin_task:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
message = first.result()
|
|
202
|
+
|
|
203
|
+
if websocket.closed:
|
|
204
|
+
break
|
|
205
|
+
|
|
206
|
+
if message.type == aiohttp.WSMsgType.CLOSE:
|
|
207
|
+
break
|
|
208
|
+
|
|
209
|
+
elif message.type == aiohttp.WSMsgType.CLOSING:
|
|
210
|
+
pass
|
|
211
|
+
|
|
212
|
+
elif message.type == aiohttp.WSMsgType.ERROR:
|
|
213
|
+
break
|
|
214
|
+
|
|
215
|
+
if not message.data:
|
|
216
|
+
break
|
|
217
|
+
|
|
218
|
+
data: bytes = message.data
|
|
219
|
+
if len(data) > 0:
|
|
220
|
+
if data[0] == 1:
|
|
221
|
+
stderr_writer.write(data)
|
|
222
|
+
await stderr_writer.drain()
|
|
223
|
+
elif data[0] == 0:
|
|
224
|
+
stdout_writer.write(data)
|
|
225
|
+
await stdout_writer.drain()
|
|
226
|
+
else:
|
|
227
|
+
raise ValueError(
|
|
228
|
+
f"Invalid channel received {data[0]}"
|
|
229
|
+
)
|
|
230
|
+
|
|
231
|
+
last_size = None
|
|
232
|
+
|
|
233
|
+
async def send_resize(rows, cols):
|
|
234
|
+
nonlocal last_size
|
|
235
|
+
|
|
236
|
+
size = (cols, rows)
|
|
237
|
+
if size == last_size:
|
|
238
|
+
return
|
|
239
|
+
|
|
240
|
+
last_size = size
|
|
241
|
+
|
|
242
|
+
resize_json = json.dumps(
|
|
243
|
+
{"Width": cols, "Height": rows}
|
|
244
|
+
).encode("utf-8")
|
|
245
|
+
payload = struct.pack("B", 4) + resize_json
|
|
246
|
+
send_queue.put_nowait(payload)
|
|
247
|
+
await asyncio.sleep(5)
|
|
248
|
+
|
|
249
|
+
cols, rows = shutil.get_terminal_size(fallback=(24, 80))
|
|
250
|
+
if tty:
|
|
251
|
+
await send_resize(rows, cols)
|
|
252
|
+
|
|
253
|
+
def on_sigwinch():
|
|
254
|
+
cols, rows = shutil.get_terminal_size(fallback=(24, 80))
|
|
255
|
+
task = asyncio.create_task(send_resize(rows, cols))
|
|
256
|
+
|
|
257
|
+
def on_done(t: asyncio.Task):
|
|
258
|
+
t.result()
|
|
259
|
+
|
|
260
|
+
task.add_done_callback(on_done)
|
|
261
|
+
|
|
262
|
+
if hasattr(signal, "SIGWINCH"):
|
|
263
|
+
loop.add_signal_handler(signal.SIGWINCH, on_sigwinch)
|
|
264
|
+
|
|
265
|
+
async def read_stdin():
|
|
266
|
+
loop = asyncio.get_running_loop()
|
|
267
|
+
|
|
268
|
+
if os.name == "nt":
|
|
269
|
+
queue: asyncio.Queue[bytes] = asyncio.Queue()
|
|
270
|
+
stop_event = threading.Event()
|
|
271
|
+
|
|
272
|
+
if sys.stdin.isatty():
|
|
273
|
+
|
|
274
|
+
def reader() -> None:
|
|
275
|
+
try:
|
|
276
|
+
while not stop_event.is_set():
|
|
277
|
+
if msvcrt.kbhit():
|
|
278
|
+
data = msvcrt.getch()
|
|
279
|
+
loop.call_soon_threadsafe(
|
|
280
|
+
queue.put_nowait, data
|
|
281
|
+
)
|
|
282
|
+
else:
|
|
283
|
+
time.sleep(0.01)
|
|
284
|
+
finally:
|
|
285
|
+
loop.call_soon_threadsafe(
|
|
286
|
+
queue.put_nowait, b""
|
|
287
|
+
)
|
|
288
|
+
else:
|
|
289
|
+
|
|
290
|
+
def reader() -> None:
|
|
291
|
+
try:
|
|
292
|
+
while not stop_event.is_set():
|
|
293
|
+
data = sys.stdin.buffer.read(1)
|
|
294
|
+
loop.call_soon_threadsafe(
|
|
295
|
+
queue.put_nowait, data
|
|
296
|
+
)
|
|
297
|
+
if not data:
|
|
298
|
+
break
|
|
299
|
+
finally:
|
|
300
|
+
loop.call_soon_threadsafe(
|
|
301
|
+
queue.put_nowait, b""
|
|
302
|
+
)
|
|
303
|
+
|
|
304
|
+
thread = threading.Thread(target=reader)
|
|
305
|
+
thread.start()
|
|
306
|
+
|
|
307
|
+
async def reader_task() -> bytes:
|
|
308
|
+
return await queue.get()
|
|
309
|
+
else:
|
|
310
|
+
reader = asyncio.StreamReader()
|
|
311
|
+
protocol = asyncio.StreamReaderProtocol(reader)
|
|
312
|
+
await loop.connect_read_pipe(
|
|
313
|
+
lambda: protocol, sys.stdin
|
|
314
|
+
)
|
|
315
|
+
|
|
316
|
+
async def reader_task():
|
|
317
|
+
return await reader.read(1)
|
|
318
|
+
|
|
319
|
+
try:
|
|
320
|
+
while True:
|
|
321
|
+
# Read one character at a time from stdin without blocking the event loop.
|
|
322
|
+
done, pending = await asyncio.wait(
|
|
323
|
+
[
|
|
324
|
+
asyncio.create_task(reader_task()),
|
|
325
|
+
websocket_recv_task,
|
|
326
|
+
],
|
|
327
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
328
|
+
)
|
|
329
|
+
|
|
330
|
+
first = done.pop()
|
|
331
|
+
if first == websocket_recv_task:
|
|
332
|
+
break
|
|
333
|
+
|
|
334
|
+
data = first.result()
|
|
335
|
+
if not data:
|
|
336
|
+
break
|
|
337
|
+
|
|
338
|
+
if websocket.closed:
|
|
339
|
+
break
|
|
340
|
+
|
|
341
|
+
if tty:
|
|
342
|
+
if data == b"\x04":
|
|
343
|
+
break
|
|
344
|
+
|
|
345
|
+
if data:
|
|
346
|
+
send_queue.put_nowait(b"\0" + data)
|
|
347
|
+
else:
|
|
348
|
+
break
|
|
349
|
+
finally:
|
|
350
|
+
if os.name == "nt":
|
|
351
|
+
stop_event.set()
|
|
352
|
+
thread.join()
|
|
353
|
+
|
|
354
|
+
send_queue.put_nowait(b"\0")
|
|
355
|
+
|
|
356
|
+
websocket_recv_task = asyncio.create_task(
|
|
357
|
+
recv_from_websocket()
|
|
358
|
+
)
|
|
359
|
+
read_stdin_task = asyncio.create_task(read_stdin())
|
|
360
|
+
|
|
361
|
+
async def send_to_websocket():
|
|
362
|
+
while True:
|
|
363
|
+
try:
|
|
364
|
+
data = await send_queue.get()
|
|
365
|
+
if websocket.closed:
|
|
366
|
+
break
|
|
367
|
+
|
|
368
|
+
if data is not None:
|
|
369
|
+
await websocket.send_bytes(data)
|
|
370
|
+
|
|
371
|
+
else:
|
|
372
|
+
break
|
|
373
|
+
except asyncio.QueueShutDown:
|
|
374
|
+
break
|
|
375
|
+
|
|
376
|
+
send_to_websocket_task = asyncio.create_task(
|
|
377
|
+
send_to_websocket()
|
|
378
|
+
)
|
|
379
|
+
await asyncio.gather(
|
|
380
|
+
websocket_recv_task,
|
|
381
|
+
read_stdin_task,
|
|
382
|
+
)
|
|
383
|
+
|
|
384
|
+
send_queue.shutdown()
|
|
385
|
+
await send_to_websocket_task
|
|
386
|
+
|
|
387
|
+
finally:
|
|
388
|
+
if not sys.stdin.closed and tty:
|
|
389
|
+
# Restore original terminal settings even if the coroutine is cancelled.
|
|
390
|
+
restore(sys.stdin, old_tty_settings)
|
|
391
|
+
|
|
392
|
+
except Exception as e:
|
|
393
|
+
print(f"[red]{e}[/red]")
|
|
394
|
+
logging.error("failed", exc_info=e)
|
|
395
|
+
raise typer.Exit(1)
|
|
396
|
+
finally:
|
|
397
|
+
await client.close()
|
meshagent/cli/helper.py
ADDED
|
@@ -0,0 +1,236 @@
|
|
|
1
|
+
import typer
|
|
2
|
+
from rich.console import Console
|
|
3
|
+
from rich.table import Table
|
|
4
|
+
from pydantic import BaseModel
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Optional
|
|
7
|
+
from meshagent.cli import auth_async
|
|
8
|
+
from meshagent.cli import async_typer
|
|
9
|
+
from meshagent.api.helpers import meshagent_base_url
|
|
10
|
+
from meshagent.api.specs.service import ServiceSpec
|
|
11
|
+
from meshagent.agents.context import AgentChatContext
|
|
12
|
+
from meshagent.api.client import Meshagent, RoomConnectionInfo
|
|
13
|
+
import os
|
|
14
|
+
import aiofiles
|
|
15
|
+
from pydantic_yaml import parse_yaml_raw_as
|
|
16
|
+
import json
|
|
17
|
+
|
|
18
|
+
from rich import print
|
|
19
|
+
|
|
20
|
+
SETTINGS_FILE = Path.home() / ".meshagent" / "project.json"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def _ensure_cache_dir():
|
|
24
|
+
SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
|
|
25
|
+
|
|
26
|
+
|
|
27
|
+
class Settings(BaseModel):
|
|
28
|
+
active_project: Optional[str] = None
|
|
29
|
+
active_api_keys: Optional[dict] = {}
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
def _save_settings(s: Settings):
|
|
33
|
+
_ensure_cache_dir()
|
|
34
|
+
SETTINGS_FILE.write_text(s.model_dump_json())
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def _load_settings():
|
|
38
|
+
try:
|
|
39
|
+
_ensure_cache_dir()
|
|
40
|
+
if SETTINGS_FILE.exists():
|
|
41
|
+
return Settings.model_validate_json(SETTINGS_FILE.read_text())
|
|
42
|
+
except OSError as ex:
|
|
43
|
+
if ex.errno == 30:
|
|
44
|
+
return Settings()
|
|
45
|
+
else:
|
|
46
|
+
raise
|
|
47
|
+
|
|
48
|
+
|
|
49
|
+
async def get_active_project():
|
|
50
|
+
settings = _load_settings()
|
|
51
|
+
if settings is None:
|
|
52
|
+
return None
|
|
53
|
+
return settings.active_project
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
async def set_active_project(project_id: str | None):
|
|
57
|
+
settings = _load_settings()
|
|
58
|
+
settings.active_project = project_id
|
|
59
|
+
_save_settings(settings)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
async def set_active_api_key(project_id: str, key: str):
|
|
63
|
+
settings = _load_settings()
|
|
64
|
+
settings.active_api_keys[project_id] = key
|
|
65
|
+
_save_settings(settings)
|
|
66
|
+
|
|
67
|
+
|
|
68
|
+
async def get_active_api_key(project_id: str):
|
|
69
|
+
settings = _load_settings()
|
|
70
|
+
if settings is None:
|
|
71
|
+
return None
|
|
72
|
+
key: str = settings.active_api_keys.get(project_id)
|
|
73
|
+
# Ignore old keys, API key format changed
|
|
74
|
+
if key is not None and key.startswith("ma-"):
|
|
75
|
+
return key
|
|
76
|
+
else:
|
|
77
|
+
return None
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
app = async_typer.AsyncTyper()
|
|
81
|
+
|
|
82
|
+
|
|
83
|
+
class CustomMeshagentClient(Meshagent):
|
|
84
|
+
async def connect_room(self, *, project_id: str, room: str) -> RoomConnectionInfo:
|
|
85
|
+
from urllib.parse import quote
|
|
86
|
+
|
|
87
|
+
jwt = os.getenv("MESHAGENT_TOKEN")
|
|
88
|
+
|
|
89
|
+
if jwt is not None and room == os.getenv("MESHAGENT_ROOM"):
|
|
90
|
+
return RoomConnectionInfo(
|
|
91
|
+
jwt=jwt,
|
|
92
|
+
room_name=room,
|
|
93
|
+
project_id=os.getenv("MESHAGENT_PROJECT_ID"),
|
|
94
|
+
room_url=meshagent_base_url() + f"/rooms/{quote(room)}",
|
|
95
|
+
)
|
|
96
|
+
|
|
97
|
+
return await super().connect_room(project_id=project_id, room=room)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
async def get_client():
|
|
101
|
+
key = os.getenv("MESHAGENT_API_KEY")
|
|
102
|
+
if key is not None or os.getenv("MESHAGENT_SESSION_ID") is not None:
|
|
103
|
+
return CustomMeshagentClient(
|
|
104
|
+
base_url=meshagent_base_url(),
|
|
105
|
+
token=key,
|
|
106
|
+
)
|
|
107
|
+
else:
|
|
108
|
+
access_token = await auth_async.get_access_token()
|
|
109
|
+
return CustomMeshagentClient(
|
|
110
|
+
base_url=meshagent_base_url(),
|
|
111
|
+
token=access_token,
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
|
|
115
|
+
def print_json_table(records: list, *cols):
|
|
116
|
+
if not records:
|
|
117
|
+
raise SystemExit("No rows to print")
|
|
118
|
+
|
|
119
|
+
# 2️⃣ --- build the table -------------------------------------------
|
|
120
|
+
table = Table(show_header=True, header_style="bold magenta")
|
|
121
|
+
|
|
122
|
+
if len(cols) > 0:
|
|
123
|
+
# use the keys of the first object as column order
|
|
124
|
+
for col in cols:
|
|
125
|
+
table.add_column(col.title()) # "id" → "Id"
|
|
126
|
+
|
|
127
|
+
for row in records:
|
|
128
|
+
table.add_row(*(str(row.get(col, "")) for col in cols))
|
|
129
|
+
|
|
130
|
+
else:
|
|
131
|
+
# use the keys of the first object as column order
|
|
132
|
+
for col in records[0]:
|
|
133
|
+
table.add_column(col.title()) # "id" → "Id"
|
|
134
|
+
|
|
135
|
+
for row in records:
|
|
136
|
+
table.add_row(*(str(row.get(col, "")) for col in records[0]))
|
|
137
|
+
|
|
138
|
+
# 3️⃣ --- render ------------------------------------------------------
|
|
139
|
+
Console().print(table)
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
def resolve_room(room_name: Optional[str] = None):
|
|
143
|
+
if room_name is None:
|
|
144
|
+
room_name = os.getenv("MESHAGENT_ROOM")
|
|
145
|
+
|
|
146
|
+
return room_name
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
async def resolve_project_id(project_id: Optional[str] = None):
|
|
150
|
+
if project_id is None:
|
|
151
|
+
project_id = os.getenv("MESHAGENT_PROJECT_ID") or await get_active_project()
|
|
152
|
+
|
|
153
|
+
if project_id is None:
|
|
154
|
+
print(
|
|
155
|
+
"[red]Project ID not specified, activate a project or pass a project on the command line[/red]"
|
|
156
|
+
)
|
|
157
|
+
raise typer.Exit(code=1)
|
|
158
|
+
|
|
159
|
+
return project_id
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
async def init_context_from_spec(context: AgentChatContext) -> None:
|
|
163
|
+
path = os.getenv("MESHAGENT_SPEC_PATH")
|
|
164
|
+
|
|
165
|
+
if path is None:
|
|
166
|
+
return None
|
|
167
|
+
|
|
168
|
+
async with aiofiles.open(path, "r") as file:
|
|
169
|
+
spec_str = await file.read()
|
|
170
|
+
try:
|
|
171
|
+
json.loads(spec_str)
|
|
172
|
+
spec = ServiceSpec.model_validate_json(spec_str)
|
|
173
|
+
except ValueError:
|
|
174
|
+
# fallback on yaml parser if spec can't
|
|
175
|
+
spec = parse_yaml_raw_as(ServiceSpec, spec_str)
|
|
176
|
+
|
|
177
|
+
readme = spec.metadata.annotations.get("meshagent.service.readme")
|
|
178
|
+
|
|
179
|
+
if spec.metadata.description:
|
|
180
|
+
context.append_assistant_message(
|
|
181
|
+
f"This agent's description:\n{spec.metadata.description}"
|
|
182
|
+
)
|
|
183
|
+
|
|
184
|
+
if readme is not None:
|
|
185
|
+
context.append_assistant_message(f"This agent's README:\n{readme}")
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
async def resolve_key(project_id: str | None, key: str | None):
|
|
189
|
+
project_id = await resolve_project_id(project_id=project_id)
|
|
190
|
+
if key is None:
|
|
191
|
+
key = await get_active_api_key(project_id=project_id)
|
|
192
|
+
|
|
193
|
+
if key is None:
|
|
194
|
+
key = os.getenv("MESHAGENT_API_KEY")
|
|
195
|
+
|
|
196
|
+
if key is None and os.getenv("MESHAGENT_TOKEN") is None:
|
|
197
|
+
print(
|
|
198
|
+
"[red]--key is required if MESHAGENT_API_KEY is not set. You can use meshagent api-key create to create a new api key."
|
|
199
|
+
)
|
|
200
|
+
raise typer.Exit(1)
|
|
201
|
+
|
|
202
|
+
return key
|
|
203
|
+
|
|
204
|
+
|
|
205
|
+
def cleanup_args(args: list[str]):
|
|
206
|
+
out = []
|
|
207
|
+
i = 0
|
|
208
|
+
while i < len(args):
|
|
209
|
+
if args[i] == "--service-name":
|
|
210
|
+
i += 1
|
|
211
|
+
elif args[i] == "--service-title":
|
|
212
|
+
i += 1
|
|
213
|
+
elif args[i] == "--service-description":
|
|
214
|
+
i += 1
|
|
215
|
+
elif args[i] == "--project-id":
|
|
216
|
+
i += 1
|
|
217
|
+
elif args[i] == "--room":
|
|
218
|
+
i += 1
|
|
219
|
+
elif args[i].startswith("--service-name="):
|
|
220
|
+
pass
|
|
221
|
+
elif args[i].startswith("--service-title="):
|
|
222
|
+
pass
|
|
223
|
+
elif args[i].startswith("--service-description="):
|
|
224
|
+
pass
|
|
225
|
+
elif args[i].startswith("--project-id="):
|
|
226
|
+
pass
|
|
227
|
+
elif args[i].startswith("--room="):
|
|
228
|
+
pass
|
|
229
|
+
elif args[i] == "deploy":
|
|
230
|
+
pass
|
|
231
|
+
elif args[i] == "spec":
|
|
232
|
+
pass
|
|
233
|
+
else:
|
|
234
|
+
out.append(args[i])
|
|
235
|
+
i += 1
|
|
236
|
+
return out
|