meshagent-cli 0.6.1__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/exec.py ADDED
@@ -0,0 +1,381 @@
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 = None,
86
+ room: RoomOption,
87
+ name: Annotated[Optional[str], typer.Option()] = None,
88
+ image: Annotated[Optional[str], typer.Option()] = None,
89
+ command: Annotated[list[str], typer.Argument(...)] = None,
90
+ tty: bool = False,
91
+ room_storage_path: str = "/data",
92
+ ):
93
+ """Open an interactive websocket‑based TTY."""
94
+ client = await get_client()
95
+ try:
96
+ project_id = await resolve_project_id(project_id=project_id)
97
+ room = resolve_room(room)
98
+
99
+ connection = await client.connect_room(project_id=project_id, room=room)
100
+
101
+ ws_url = (
102
+ websocket_room_url(room_name=room) + f"/exec?token={connection.jwt}"
103
+ )
104
+
105
+ if image:
106
+ ws_url += f"&image={quote(' '.join(image))}"
107
+
108
+ if name:
109
+ ws_url += f"&name={quote(' '.join(name))}"
110
+
111
+ if command and len(command) != 0:
112
+ ws_url += f"&command={quote(' '.join(command))}"
113
+
114
+ if room_storage_path:
115
+ room_storage_path += (
116
+ f"&room_storage_path={quote(' '.join(room_storage_path))}"
117
+ )
118
+
119
+ if tty:
120
+ if not sys.stdin.isatty():
121
+ print("[red]TTY requested but process is not a TTY[/red]")
122
+ raise typer.Exit(1)
123
+
124
+ ws_url += "&tty=true"
125
+
126
+ else:
127
+ if command is None:
128
+ print("[red]TTY required when not executing a command[/red]")
129
+ raise typer.Exit(1)
130
+
131
+ ws_url += "&tty=false"
132
+
133
+ if tty:
134
+ # Save current terminal settings so we can restore them later.
135
+ old_tty_settings = set_raw(sys.stdin)
136
+
137
+ async with RoomClient(
138
+ protocol=WebSocketClientProtocol(
139
+ url=websocket_room_url(room_name=room),
140
+ token=connection.jwt,
141
+ )
142
+ ):
143
+ try:
144
+ async with aiohttp.ClientSession() as session:
145
+ async with session.ws_connect(ws_url) as websocket:
146
+ send_queue = asyncio.Queue[bytes]()
147
+
148
+ loop = asyncio.get_running_loop()
149
+ if os.name == "nt":
150
+ stdout_writer = _StdWriter(sys.stdout)
151
+ stderr_writer = _StdWriter(sys.stderr)
152
+ else:
153
+ (
154
+ stdout_transport,
155
+ stdout_protocol,
156
+ ) = await loop.connect_write_pipe(
157
+ asyncio.streams.FlowControlMixin, sys.stdout
158
+ )
159
+ stdout_writer = asyncio.StreamWriter(
160
+ stdout_transport, stdout_protocol, None, loop
161
+ )
162
+
163
+ (
164
+ stderr_transport,
165
+ stderr_protocol,
166
+ ) = await loop.connect_write_pipe(
167
+ asyncio.streams.FlowControlMixin, sys.stderr
168
+ )
169
+ stderr_writer = asyncio.StreamWriter(
170
+ stderr_transport, stderr_protocol, None, loop
171
+ )
172
+
173
+ async def recv_from_websocket():
174
+ while True:
175
+ done, pending = await asyncio.wait(
176
+ [asyncio.create_task(websocket.receive())],
177
+ return_when=asyncio.FIRST_COMPLETED,
178
+ )
179
+
180
+ first = done.pop()
181
+
182
+ if first == read_stdin_task:
183
+ break
184
+
185
+ message = first.result()
186
+
187
+ if websocket.closed:
188
+ break
189
+
190
+ if message.type == aiohttp.WSMsgType.CLOSE:
191
+ break
192
+
193
+ elif message.type == aiohttp.WSMsgType.CLOSING:
194
+ pass
195
+
196
+ elif message.type == aiohttp.WSMsgType.ERROR:
197
+ break
198
+
199
+ if not message.data:
200
+ break
201
+
202
+ data: bytes = message.data
203
+ if len(data) > 0:
204
+ if data[0] == 1:
205
+ stderr_writer.write(data)
206
+ await stderr_writer.drain()
207
+ elif data[0] == 0:
208
+ stdout_writer.write(data)
209
+ await stdout_writer.drain()
210
+ else:
211
+ raise ValueError(
212
+ f"Invalid channel received {data[0]}"
213
+ )
214
+
215
+ last_size = None
216
+
217
+ async def send_resize(rows, cols):
218
+ nonlocal last_size
219
+
220
+ size = (cols, rows)
221
+ if size == last_size:
222
+ return
223
+
224
+ last_size = size
225
+
226
+ resize_json = json.dumps(
227
+ {"Width": cols, "Height": rows}
228
+ ).encode("utf-8")
229
+ payload = struct.pack("B", 4) + resize_json
230
+ send_queue.put_nowait(payload)
231
+ await asyncio.sleep(5)
232
+
233
+ cols, rows = shutil.get_terminal_size(fallback=(24, 80))
234
+ if tty:
235
+ await send_resize(rows, cols)
236
+
237
+ def on_sigwinch():
238
+ cols, rows = shutil.get_terminal_size(fallback=(24, 80))
239
+ task = asyncio.create_task(send_resize(rows, cols))
240
+
241
+ def on_done(t: asyncio.Task):
242
+ t.result()
243
+
244
+ task.add_done_callback(on_done)
245
+
246
+ if hasattr(signal, "SIGWINCH"):
247
+ loop.add_signal_handler(signal.SIGWINCH, on_sigwinch)
248
+
249
+ async def read_stdin():
250
+ loop = asyncio.get_running_loop()
251
+
252
+ if os.name == "nt":
253
+ queue: asyncio.Queue[bytes] = asyncio.Queue()
254
+ stop_event = threading.Event()
255
+
256
+ if sys.stdin.isatty():
257
+
258
+ def reader() -> None:
259
+ try:
260
+ while not stop_event.is_set():
261
+ if msvcrt.kbhit():
262
+ data = msvcrt.getch()
263
+ loop.call_soon_threadsafe(
264
+ queue.put_nowait, data
265
+ )
266
+ else:
267
+ time.sleep(0.01)
268
+ finally:
269
+ loop.call_soon_threadsafe(
270
+ queue.put_nowait, b""
271
+ )
272
+ else:
273
+
274
+ def reader() -> None:
275
+ try:
276
+ while not stop_event.is_set():
277
+ data = sys.stdin.buffer.read(1)
278
+ loop.call_soon_threadsafe(
279
+ queue.put_nowait, data
280
+ )
281
+ if not data:
282
+ break
283
+ finally:
284
+ loop.call_soon_threadsafe(
285
+ queue.put_nowait, b""
286
+ )
287
+
288
+ thread = threading.Thread(target=reader)
289
+ thread.start()
290
+
291
+ async def reader_task() -> bytes:
292
+ return await queue.get()
293
+ else:
294
+ reader = asyncio.StreamReader()
295
+ protocol = asyncio.StreamReaderProtocol(reader)
296
+ await loop.connect_read_pipe(
297
+ lambda: protocol, sys.stdin
298
+ )
299
+
300
+ async def reader_task():
301
+ return await reader.read(1)
302
+
303
+ try:
304
+ while True:
305
+ # Read one character at a time from stdin without blocking the event loop.
306
+ done, pending = await asyncio.wait(
307
+ [
308
+ asyncio.create_task(reader_task()),
309
+ websocket_recv_task,
310
+ ],
311
+ return_when=asyncio.FIRST_COMPLETED,
312
+ )
313
+
314
+ first = done.pop()
315
+ if first == websocket_recv_task:
316
+ break
317
+
318
+ data = first.result()
319
+ if not data:
320
+ break
321
+
322
+ if websocket.closed:
323
+ break
324
+
325
+ if tty:
326
+ if data == b"\x04":
327
+ break
328
+
329
+ if data:
330
+ send_queue.put_nowait(b"\0" + data)
331
+ else:
332
+ break
333
+ finally:
334
+ if os.name == "nt":
335
+ stop_event.set()
336
+ thread.join()
337
+
338
+ send_queue.put_nowait(b"\0")
339
+
340
+ websocket_recv_task = asyncio.create_task(
341
+ recv_from_websocket()
342
+ )
343
+ read_stdin_task = asyncio.create_task(read_stdin())
344
+
345
+ async def send_to_websocket():
346
+ while True:
347
+ try:
348
+ data = await send_queue.get()
349
+ if websocket.closed:
350
+ break
351
+
352
+ if data is not None:
353
+ await websocket.send_bytes(data)
354
+
355
+ else:
356
+ break
357
+ except asyncio.QueueShutDown:
358
+ break
359
+
360
+ send_to_websocket_task = asyncio.create_task(
361
+ send_to_websocket()
362
+ )
363
+ await asyncio.gather(
364
+ websocket_recv_task,
365
+ read_stdin_task,
366
+ )
367
+
368
+ send_queue.shutdown()
369
+ await send_to_websocket_task
370
+
371
+ finally:
372
+ if not sys.stdin.closed and tty:
373
+ # Restore original terminal settings even if the coroutine is cancelled.
374
+ restore(sys.stdin, old_tty_settings)
375
+
376
+ except Exception as e:
377
+ print(f"[red]{e}[/red]")
378
+ logging.error("failed", exc_info=e)
379
+ raise typer.Exit(1)
380
+ finally:
381
+ await client.close()
@@ -0,0 +1,147 @@
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.client import Meshagent
11
+ import os
12
+ from rich import print
13
+
14
+ SETTINGS_FILE = Path.home() / ".meshagent" / "project.json"
15
+
16
+
17
+ def _ensure_cache_dir():
18
+ SETTINGS_FILE.parent.mkdir(parents=True, exist_ok=True)
19
+
20
+
21
+ class Settings(BaseModel):
22
+ active_project: Optional[str] = None
23
+ active_api_keys: Optional[dict] = {}
24
+
25
+
26
+ def _save_settings(s: Settings):
27
+ _ensure_cache_dir()
28
+ SETTINGS_FILE.write_text(s.model_dump_json())
29
+
30
+
31
+ def _load_settings():
32
+ _ensure_cache_dir()
33
+ if SETTINGS_FILE.exists():
34
+ return Settings.model_validate_json(SETTINGS_FILE.read_text())
35
+
36
+ return Settings()
37
+
38
+
39
+ async def get_active_project():
40
+ settings = _load_settings()
41
+ if settings is None:
42
+ return None
43
+ return settings.active_project
44
+
45
+
46
+ async def set_active_project(project_id: str | None):
47
+ settings = _load_settings()
48
+ settings.active_project = project_id
49
+ _save_settings(settings)
50
+
51
+
52
+ async def set_active_api_key(project_id: str, key: str):
53
+ settings = _load_settings()
54
+ settings.active_api_keys[project_id] = key
55
+ _save_settings(settings)
56
+
57
+
58
+ async def get_active_api_key(project_id: str):
59
+ settings = _load_settings()
60
+ key: str = settings.active_api_keys.get(project_id)
61
+ # Ignore old keys, API key format changed
62
+ if key is not None and key.startswith("ma-"):
63
+ return key
64
+ else:
65
+ return None
66
+
67
+
68
+ app = async_typer.AsyncTyper()
69
+
70
+
71
+ async def get_client():
72
+ key = os.getenv("MESHAGENT_API_KEY")
73
+ if key is not None:
74
+ return Meshagent(
75
+ base_url=meshagent_base_url(),
76
+ token=key,
77
+ )
78
+ else:
79
+ access_token = await auth_async.get_access_token()
80
+ return Meshagent(
81
+ base_url=meshagent_base_url(),
82
+ token=access_token,
83
+ )
84
+
85
+
86
+ def print_json_table(records: list, *cols):
87
+ if not records:
88
+ raise SystemExit("No rows to print")
89
+
90
+ # 2️⃣ --- build the table -------------------------------------------
91
+ table = Table(show_header=True, header_style="bold magenta")
92
+
93
+ if len(cols) > 0:
94
+ # use the keys of the first object as column order
95
+ for col in cols:
96
+ table.add_column(col.title()) # "id" → "Id"
97
+
98
+ for row in records:
99
+ table.add_row(*(str(row.get(col, "")) for col in cols))
100
+
101
+ else:
102
+ # use the keys of the first object as column order
103
+ for col in records[0]:
104
+ table.add_column(col.title()) # "id" → "Id"
105
+
106
+ for row in records:
107
+ table.add_row(*(str(row.get(col, "")) for col in records[0]))
108
+
109
+ # 3️⃣ --- render ------------------------------------------------------
110
+ Console().print(table)
111
+
112
+
113
+ def resolve_room(room_name: Optional[str] = None):
114
+ if room_name is None:
115
+ room_name = os.getenv("MESHAGENT_ROOM")
116
+
117
+ return room_name
118
+
119
+
120
+ async def resolve_project_id(project_id: Optional[str] = None):
121
+ if project_id is None:
122
+ project_id = await get_active_project()
123
+
124
+ if project_id is None:
125
+ print(
126
+ "[red]Project ID not specified, activate a project or pass a project on the command line[/red]"
127
+ )
128
+ raise typer.Exit(code=1)
129
+
130
+ return project_id
131
+
132
+
133
+ async def resolve_key(project_id: str | None, key: str | None):
134
+ project_id = await resolve_project_id(project_id=project_id)
135
+ if key is None:
136
+ key = await get_active_api_key(project_id=project_id)
137
+
138
+ if key is None:
139
+ key = os.getenv("MESHAGENT_API_KEY")
140
+
141
+ if key is None:
142
+ print(
143
+ "[red]--key is required if MESHGENT_API_KEY is not set. You can use meshagent api-key create to create a new api key."
144
+ )
145
+ raise typer.Exit(1)
146
+
147
+ return key
@@ -0,0 +1,131 @@
1
+ from meshagent.cli import async_typer
2
+
3
+
4
+ from meshagent.api import SchemaRegistry, SchemaRegistration
5
+
6
+
7
+ import logging
8
+
9
+ app = async_typer.AsyncTyper(help="Join a mailbot to a room")
10
+
11
+
12
+ @app.async_command("service")
13
+ async def helpers_service():
14
+ from meshagent.agents.planning import DynamicPlanningResponder, PlanningResponder
15
+ from meshagent.openai.tools import OpenAIResponsesAdapter
16
+ from meshagent.tools.storage import StorageToolkit
17
+ from meshagent.api.services import ServiceHost
18
+
19
+ from meshagent.agents.schemas.gallery import gallery_schema
20
+ from meshagent.agents.schemas.document import document_schema
21
+ from meshagent.agents.schemas.transcript import transcript_schema
22
+ from meshagent.agents.schemas.super_editor_document import (
23
+ super_editor_document_schema,
24
+ )
25
+ from meshagent.agents.schemas.presentation import presentation_schema
26
+ from meshagent.agents import thread_schema
27
+
28
+ logging.getLogger("openai").setLevel(logging.ERROR)
29
+ logging.getLogger("httpx").setLevel(logging.ERROR)
30
+
31
+ service = ServiceHost(port=9000)
32
+
33
+ @service.path("/planner")
34
+ class Planner(PlanningResponder):
35
+ def __init__(self, **kwargs):
36
+ super().__init__(
37
+ name="meshagent.planner",
38
+ title="Generic Task Runner",
39
+ description="an agent that will perform a task with the selected tools",
40
+ llm_adapter=OpenAIResponsesAdapter(model="gpt-4.1"),
41
+ supports_tools=True,
42
+ input_prompt=True,
43
+ output_schema={
44
+ "type": "object",
45
+ "required": ["result"],
46
+ "additionalProperties": False,
47
+ "properties": {"result": {"type": "string"}},
48
+ },
49
+ )
50
+
51
+ @service.path("/schema_planner")
52
+ class DynamicPlanner(DynamicPlanningResponder):
53
+ def __init__(self, **kwargs):
54
+ super().__init__(
55
+ name="meshagent.schema_planner",
56
+ title="Schema Task Runner",
57
+ description="an agent that can produces output that matches a schema",
58
+ llm_adapter=OpenAIResponsesAdapter(model="gpt-4.1"),
59
+ )
60
+
61
+ @service.path("/schemas/document")
62
+ class DocumentSchemaRegistry(SchemaRegistry):
63
+ def __init__(self):
64
+ name = "document"
65
+ schema = document_schema
66
+ super().__init__(
67
+ name=f"meshagent.schema.{name}",
68
+ validate_webhook_secret=False,
69
+ schemas=[SchemaRegistration(name=name, schema=schema)],
70
+ )
71
+
72
+ @service.path("/schemas/superdoc")
73
+ class SuperdocDocumentSchemaRegistry(SchemaRegistry):
74
+ def __init__(self):
75
+ name = "superdoc"
76
+ schema = super_editor_document_schema
77
+ super().__init__(
78
+ name=f"meshagent.schema.{name}",
79
+ validate_webhook_secret=False,
80
+ schemas=[SchemaRegistration(name=name, schema=schema)],
81
+ )
82
+
83
+ @service.path("/schemas/gallery")
84
+ class GalleryDocumentSchemaRegistry(SchemaRegistry):
85
+ def __init__(self):
86
+ name = "gallery"
87
+ schema = gallery_schema
88
+ super().__init__(
89
+ name=f"meshagent.schema.{name}",
90
+ validate_webhook_secret=False,
91
+ schemas=[SchemaRegistration(name=name, schema=schema)],
92
+ )
93
+
94
+ @service.path("/schemas/thread")
95
+ class ThreadDocumentSchemaRegistry(SchemaRegistry):
96
+ def __init__(self):
97
+ name = "thread"
98
+ schema = thread_schema
99
+ super().__init__(
100
+ name=f"meshagent.schema.{name}",
101
+ validate_webhook_secret=False,
102
+ schemas=[SchemaRegistration(name=name, schema=schema)],
103
+ )
104
+
105
+ @service.path("/schemas/presentation")
106
+ class PresentationDocumentSchemaRegistry(SchemaRegistry):
107
+ def __init__(presentation):
108
+ name = "presentation"
109
+ schema = presentation_schema
110
+ super().__init__(
111
+ name=f"meshagent.schema.{name}",
112
+ validate_webhook_secret=False,
113
+ schemas=[SchemaRegistration(name=name, schema=schema)],
114
+ )
115
+
116
+ @service.path("/schemas/transcript")
117
+ class TranscriptRegistry(SchemaRegistry):
118
+ def __init__(self):
119
+ name = "transcript"
120
+ schema = transcript_schema
121
+ super().__init__(
122
+ name=f"meshagent.schema.{name}",
123
+ validate_webhook_secret=False,
124
+ schemas=[SchemaRegistration(name=name, schema=schema)],
125
+ )
126
+
127
+ @service.path("/toolkits/storage")
128
+ class HostedStorageToolkit(StorageToolkit):
129
+ pass
130
+
131
+ await service.run()