portacode 0.2.1.dev0__tar.gz → 0.2.3.dev0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (36) hide show
  1. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/PKG-INFO +1 -1
  2. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/_version.py +2 -2
  3. portacode-0.2.3.dev0/portacode/connection/handlers/__init__.py +28 -0
  4. portacode-0.2.3.dev0/portacode/connection/handlers/base.py +115 -0
  5. portacode-0.2.3.dev0/portacode/connection/handlers/registry.py +111 -0
  6. portacode-0.2.3.dev0/portacode/connection/handlers/session.py +275 -0
  7. portacode-0.2.3.dev0/portacode/connection/handlers/system_handlers.py +32 -0
  8. portacode-0.2.3.dev0/portacode/connection/handlers/terminal_handlers.py +139 -0
  9. portacode-0.2.3.dev0/portacode/connection/terminal.py +177 -0
  10. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/PKG-INFO +1 -1
  11. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/SOURCES.txt +7 -1
  12. portacode-0.2.1.dev0/portacode/connection/terminal.py +0 -501
  13. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/.gitignore +0 -0
  14. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/.gitmodules +0 -0
  15. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/MANIFEST.in +0 -0
  16. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/Makefile +0 -0
  17. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/README.md +0 -0
  18. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/docker-compose.yaml +0 -0
  19. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/README.md +0 -0
  20. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/__init__.py +0 -0
  21. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/__main__.py +0 -0
  22. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/cli.py +0 -0
  23. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/README.md +0 -0
  24. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/__init__.py +0 -0
  25. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/client.py +0 -0
  26. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/connection/multiplex.py +0 -0
  27. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/data.py +0 -0
  28. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/keypair.py +0 -0
  29. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode/service.py +0 -0
  30. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/dependency_links.txt +0 -0
  31. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/entry_points.txt +0 -0
  32. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/requires.txt +0 -0
  33. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/portacode.egg-info/top_level.txt +0 -0
  34. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/pyproject.toml +0 -0
  35. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/setup.cfg +0 -0
  36. {portacode-0.2.1.dev0 → portacode-0.2.3.dev0}/setup.py +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.1
2
2
  Name: portacode
3
- Version: 0.2.1.dev0
3
+ Version: 0.2.3.dev0
4
4
  Summary: Portacode CLI client and SDK
5
5
  Home-page: https://github.com/portacode/portacode
6
6
  Author: Meena Erian
@@ -17,5 +17,5 @@ __version__: str
17
17
  __version_tuple__: VERSION_TUPLE
18
18
  version_tuple: VERSION_TUPLE
19
19
 
20
- __version__ = version = '0.2.1.dev'
21
- __version_tuple__ = version_tuple = (0, 2, 1, 'dev0')
20
+ __version__ = version = '0.2.3.dev'
21
+ __version_tuple__ = version_tuple = (0, 2, 3, 'dev0')
@@ -0,0 +1,28 @@
1
+ """Modular command handler system for Portacode client.
2
+
3
+ This package provides a flexible system for handling commands from the gateway.
4
+ Handlers can be easily added, removed, or modified without touching the main
5
+ terminal manager code.
6
+ """
7
+
8
+ from .base import BaseHandler, AsyncHandler, SyncHandler
9
+ from .registry import CommandRegistry
10
+ from .terminal_handlers import (
11
+ TerminalStartHandler,
12
+ TerminalSendHandler,
13
+ TerminalStopHandler,
14
+ TerminalListHandler,
15
+ )
16
+ from .system_handlers import SystemInfoHandler
17
+
18
+ __all__ = [
19
+ "BaseHandler",
20
+ "AsyncHandler",
21
+ "SyncHandler",
22
+ "CommandRegistry",
23
+ "TerminalStartHandler",
24
+ "TerminalSendHandler",
25
+ "TerminalStopHandler",
26
+ "TerminalListHandler",
27
+ "SystemInfoHandler",
28
+ ]
@@ -0,0 +1,115 @@
1
+ """Base handler classes for command processing."""
2
+
3
+ import asyncio
4
+ import logging
5
+ from abc import ABC, abstractmethod
6
+ from typing import Any, Dict, Optional, TYPE_CHECKING
7
+
8
+ if TYPE_CHECKING:
9
+ from ..multiplex import Channel
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class BaseHandler(ABC):
15
+ """Base class for all command handlers."""
16
+
17
+ def __init__(self, control_channel: "Channel", context: Dict[str, Any]):
18
+ """Initialize the handler.
19
+
20
+ Args:
21
+ control_channel: The control channel for sending responses
22
+ context: Shared context containing terminal manager state
23
+ """
24
+ self.control_channel = control_channel
25
+ self.context = context
26
+
27
+ @property
28
+ @abstractmethod
29
+ def command_name(self) -> str:
30
+ """Return the command name this handler processes."""
31
+ pass
32
+
33
+ @abstractmethod
34
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
35
+ """Handle the command message.
36
+
37
+ Args:
38
+ message: The command message dict
39
+ reply_channel: Optional reply channel for responses
40
+ """
41
+ pass
42
+
43
+ async def send_response(self, payload: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
44
+ """Send a response back to the gateway.
45
+
46
+ Args:
47
+ payload: Response payload
48
+ reply_channel: Optional reply channel
49
+ """
50
+ if reply_channel:
51
+ payload["reply_channel"] = reply_channel
52
+ await self.control_channel.send(payload)
53
+
54
+ async def send_error(self, message: str, reply_channel: Optional[str] = None) -> None:
55
+ """Send an error response.
56
+
57
+ Args:
58
+ message: Error message
59
+ reply_channel: Optional reply channel
60
+ """
61
+ payload = {"event": "error", "message": message}
62
+ if reply_channel:
63
+ payload["reply_channel"] = reply_channel
64
+ await self.control_channel.send(payload)
65
+
66
+
67
+ class AsyncHandler(BaseHandler):
68
+ """Base class for asynchronous command handlers."""
69
+
70
+ @abstractmethod
71
+ async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
72
+ """Execute the command logic asynchronously.
73
+
74
+ Args:
75
+ message: The command message dict
76
+
77
+ Returns:
78
+ Response payload dict
79
+ """
80
+ pass
81
+
82
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
83
+ """Handle the command by executing it and sending the response."""
84
+ try:
85
+ response = await self.execute(message)
86
+ await self.send_response(response, reply_channel)
87
+ except Exception as exc:
88
+ logger.exception("Error in async handler %s: %s", self.command_name, exc)
89
+ await self.send_error(str(exc), reply_channel)
90
+
91
+
92
+ class SyncHandler(BaseHandler):
93
+ """Base class for synchronous command handlers."""
94
+
95
+ @abstractmethod
96
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
97
+ """Execute the command logic synchronously.
98
+
99
+ Args:
100
+ message: The command message dict
101
+
102
+ Returns:
103
+ Response payload dict
104
+ """
105
+ pass
106
+
107
+ async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
108
+ """Handle the command by executing it in an executor and sending the response."""
109
+ try:
110
+ loop = asyncio.get_running_loop()
111
+ response = await loop.run_in_executor(None, self.execute, message)
112
+ await self.send_response(response, reply_channel)
113
+ except Exception as exc:
114
+ logger.exception("Error in sync handler %s: %s", self.command_name, exc)
115
+ await self.send_error(str(exc), reply_channel)
@@ -0,0 +1,111 @@
1
+ """Command registry for managing handler dispatch."""
2
+
3
+ import logging
4
+ from typing import Dict, Type, Any, Optional, List, TYPE_CHECKING
5
+
6
+ if TYPE_CHECKING:
7
+ from ..multiplex import Channel
8
+ from .base import BaseHandler
9
+
10
+ logger = logging.getLogger(__name__)
11
+
12
+
13
+ class CommandRegistry:
14
+ """Registry for managing command handlers."""
15
+
16
+ def __init__(self, control_channel: "Channel", context: Dict[str, Any]):
17
+ """Initialize the command registry.
18
+
19
+ Args:
20
+ control_channel: The control channel for handlers
21
+ context: Shared context for handlers
22
+ """
23
+ self.control_channel = control_channel
24
+ self.context = context
25
+ self._handlers: Dict[str, "BaseHandler"] = {}
26
+
27
+ def register(self, handler_class: Type["BaseHandler"]) -> None:
28
+ """Register a command handler.
29
+
30
+ Args:
31
+ handler_class: The handler class to register
32
+ """
33
+ handler_instance = handler_class(self.control_channel, self.context)
34
+ command_name = handler_instance.command_name
35
+
36
+ if command_name in self._handlers:
37
+ logger.warning("Overriding existing handler for command: %s", command_name)
38
+
39
+ self._handlers[command_name] = handler_instance
40
+ logger.debug("Registered handler for command: %s", command_name)
41
+
42
+ def unregister(self, command_name: str) -> None:
43
+ """Unregister a command handler.
44
+
45
+ Args:
46
+ command_name: The command name to unregister
47
+ """
48
+ if command_name in self._handlers:
49
+ del self._handlers[command_name]
50
+ logger.debug("Unregistered handler for command: %s", command_name)
51
+ else:
52
+ logger.warning("Attempted to unregister non-existent handler: %s", command_name)
53
+
54
+ def get_handler(self, command_name: str) -> Optional["BaseHandler"]:
55
+ """Get a handler by command name.
56
+
57
+ Args:
58
+ command_name: The command name
59
+
60
+ Returns:
61
+ The handler instance or None if not found
62
+ """
63
+ return self._handlers.get(command_name)
64
+
65
+ def list_commands(self) -> List[str]:
66
+ """List all registered command names.
67
+
68
+ Returns:
69
+ List of command names
70
+ """
71
+ return list(self._handlers.keys())
72
+
73
+ async def dispatch(self, command_name: str, message: Dict[str, Any], reply_channel: Optional[str] = None) -> bool:
74
+ """Dispatch a command to its handler.
75
+
76
+ Args:
77
+ command_name: The command name
78
+ message: The command message
79
+ reply_channel: Optional reply channel
80
+
81
+ Returns:
82
+ True if handler was found and executed, False otherwise
83
+ """
84
+ handler = self.get_handler(command_name)
85
+ if handler is None:
86
+ logger.warning("No handler found for command: %s", command_name)
87
+ return False
88
+
89
+ try:
90
+ await handler.handle(message, reply_channel)
91
+ return True
92
+ except Exception as exc:
93
+ logger.exception("Error dispatching command %s: %s", command_name, exc)
94
+ # Send error response
95
+ error_payload = {"event": "error", "message": str(exc)}
96
+ if reply_channel:
97
+ error_payload["reply_channel"] = reply_channel
98
+ await self.control_channel.send(error_payload)
99
+ return False
100
+
101
+ def update_context(self, context: Dict[str, Any]) -> None:
102
+ """Update the shared context for all handlers.
103
+
104
+ Args:
105
+ context: New context dict
106
+ """
107
+ self.context.update(context)
108
+
109
+ # Update context for all existing handlers
110
+ for handler in self._handlers.values():
111
+ handler.context = self.context
@@ -0,0 +1,275 @@
1
+ """Terminal session management."""
2
+
3
+ import asyncio
4
+ import logging
5
+ import os
6
+ import sys
7
+ import uuid
8
+ from asyncio.subprocess import Process
9
+ from pathlib import Path
10
+ from typing import Any, Dict, Optional, List, TYPE_CHECKING
11
+ from collections import deque
12
+
13
+ if TYPE_CHECKING:
14
+ from ..multiplex import Channel
15
+
16
+ logger = logging.getLogger(__name__)
17
+
18
+ _IS_WINDOWS = sys.platform.startswith("win")
19
+
20
+ # Minimal, safe defaults for interactive shells
21
+ _DEFAULT_ENV = {
22
+ "TERM": "xterm-256color",
23
+ "LANG": "C.UTF-8",
24
+ }
25
+
26
+
27
+ def _build_child_env() -> Dict[str, str]:
28
+ """Return a copy of os.environ with sensible fallbacks added."""
29
+ env = os.environ.copy()
30
+ for k, v in _DEFAULT_ENV.items():
31
+ env.setdefault(k, v)
32
+ return env
33
+
34
+
35
+ class TerminalSession:
36
+ """Represents a local shell subprocess bound to a mux channel."""
37
+
38
+ def __init__(self, session_id: str, proc: Process, channel: "Channel"):
39
+ self.id = session_id
40
+ self.proc = proc
41
+ self.channel = channel
42
+ self._reader_task: Optional[asyncio.Task[None]] = None
43
+ self._buffer: deque[str] = deque(maxlen=400)
44
+
45
+ async def start_io_forwarding(self) -> None:
46
+ """Spawn background task that copies stdout/stderr to the channel."""
47
+ assert self.proc.stdout is not None, "stdout pipe not set"
48
+
49
+ async def _pump() -> None:
50
+ try:
51
+ while True:
52
+ data = await self.proc.stdout.read(1024)
53
+ if not data:
54
+ break
55
+ text = data.decode(errors="ignore")
56
+ logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
57
+ self._buffer.append(text)
58
+ try:
59
+ await self.channel.send(text)
60
+ except Exception as exc:
61
+ logger.warning("Failed to forward terminal output: %s", exc)
62
+ await asyncio.sleep(0.5)
63
+ continue
64
+ finally:
65
+ if self.proc and self.proc.returncode is None:
66
+ pass # Keep alive across reconnects
67
+
68
+ self._reader_task = asyncio.create_task(_pump())
69
+
70
+ async def write(self, data: str) -> None:
71
+ if self.proc.stdin is None:
72
+ logger.warning("stdin pipe closed for terminal %s", self.id)
73
+ return
74
+ try:
75
+ self.proc.stdin.write(data.encode())
76
+ await self.proc.stdin.drain()
77
+ except Exception as exc:
78
+ logger.warning("Failed to write to terminal %s: %s", self.id, exc)
79
+
80
+ async def stop(self) -> None:
81
+ if self.proc.returncode is None:
82
+ self.proc.terminate()
83
+ if self._reader_task:
84
+ await self._reader_task
85
+ await self.proc.wait()
86
+
87
+ def snapshot_buffer(self) -> str:
88
+ """Return concatenated last buffer contents suitable for UI."""
89
+ return "".join(self._buffer)
90
+
91
+
92
+ class WindowsTerminalSession(TerminalSession):
93
+ """Terminal session backed by a Windows ConPTY."""
94
+
95
+ def __init__(self, session_id: str, pty, channel: "Channel"):
96
+ # Create a proxy for the PTY process
97
+ class _WinPTYProxy:
98
+ def __init__(self, pty):
99
+ self._pty = pty
100
+
101
+ @property
102
+ def pid(self):
103
+ return self._pty.pid
104
+
105
+ @property
106
+ def returncode(self):
107
+ return None if self._pty.isalive() else self._pty.exitstatus
108
+
109
+ async def wait(self):
110
+ loop = asyncio.get_running_loop()
111
+ await loop.run_in_executor(None, self._pty.wait)
112
+
113
+ super().__init__(session_id, _WinPTYProxy(pty), channel)
114
+ self._pty = pty
115
+
116
+ async def start_io_forwarding(self) -> None:
117
+ """Spawn background task that copies stdout/stderr to the channel."""
118
+ loop = asyncio.get_running_loop()
119
+
120
+ async def _pump() -> None:
121
+ try:
122
+ while True:
123
+ data = await loop.run_in_executor(None, self._pty.read, 1024)
124
+ if not data:
125
+ if not self._pty.isalive():
126
+ break
127
+ await asyncio.sleep(0.05)
128
+ continue
129
+ if isinstance(data, bytes):
130
+ text = data.decode(errors="ignore")
131
+ else:
132
+ text = data
133
+ logging.getLogger("portacode.terminal").debug(f"[MUX] Terminal {self.id} output: {text!r}")
134
+ self._buffer.append(text)
135
+ try:
136
+ await self.channel.send(text)
137
+ except Exception as exc:
138
+ logger.warning("Failed to forward terminal output: %s", exc)
139
+ await asyncio.sleep(0.5)
140
+ continue
141
+ finally:
142
+ if self._pty and self._pty.isalive():
143
+ self._pty.kill()
144
+
145
+ self._reader_task = asyncio.create_task(_pump())
146
+
147
+ async def write(self, data: str) -> None:
148
+ loop = asyncio.get_running_loop()
149
+ try:
150
+ await loop.run_in_executor(None, self._pty.write, data)
151
+ except Exception as exc:
152
+ logger.warning("Failed to write to terminal %s: %s", self.id, exc)
153
+
154
+ async def stop(self) -> None:
155
+ if self._pty.isalive():
156
+ self._pty.kill()
157
+ if self._reader_task:
158
+ await self._reader_task
159
+
160
+
161
+ class SessionManager:
162
+ """Manages terminal sessions."""
163
+
164
+ def __init__(self, mux):
165
+ self.mux = mux
166
+ self._sessions: Dict[str, TerminalSession] = {}
167
+ self._next_channel = 100
168
+
169
+ def _allocate_channel_id(self) -> int:
170
+ """Allocate a new channel ID for a terminal session."""
171
+ cid = self._next_channel
172
+ self._next_channel += 1
173
+ return cid
174
+
175
+ async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
176
+ """Create a new terminal session."""
177
+ term_id = uuid.uuid4().hex
178
+ channel_id = self._allocate_channel_id()
179
+ channel = self.mux.get_channel(channel_id)
180
+
181
+ # Choose shell
182
+ if shell is None:
183
+ shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
184
+
185
+ logger.info("Launching terminal %s using shell=%s on channel=%d", term_id, shell, channel_id)
186
+
187
+ if _IS_WINDOWS:
188
+ try:
189
+ from winpty import PtyProcess
190
+ except ImportError as exc:
191
+ logger.error("winpty (pywinpty) not found: %s", exc)
192
+ raise RuntimeError("pywinpty not installed on client")
193
+
194
+ pty_proc = PtyProcess.spawn(shell, cwd=cwd or None, env=_build_child_env())
195
+ session = WindowsTerminalSession(term_id, pty_proc, channel)
196
+ else:
197
+ # Unix: try real PTY for proper TTY semantics
198
+ try:
199
+ import pty
200
+ master_fd, slave_fd = pty.openpty()
201
+ proc = await asyncio.create_subprocess_exec(
202
+ shell,
203
+ stdin=slave_fd,
204
+ stdout=slave_fd,
205
+ stderr=slave_fd,
206
+ preexec_fn=os.setsid,
207
+ cwd=cwd,
208
+ env=_build_child_env(),
209
+ )
210
+ # Wrap master_fd into a StreamReader
211
+ loop = asyncio.get_running_loop()
212
+ reader = asyncio.StreamReader()
213
+ protocol = asyncio.StreamReaderProtocol(reader)
214
+ await loop.connect_read_pipe(lambda: protocol, os.fdopen(master_fd, "rb", buffering=0))
215
+ proc.stdout = reader
216
+ # Use writer for stdin
217
+ writer_transport, writer_protocol = await loop.connect_write_pipe(
218
+ lambda: asyncio.Protocol(), os.fdopen(master_fd, "wb", buffering=0)
219
+ )
220
+ proc.stdin = asyncio.StreamWriter(writer_transport, writer_protocol, reader, loop)
221
+ except Exception:
222
+ logger.warning("Failed to allocate PTY, falling back to pipes")
223
+ proc = await asyncio.create_subprocess_exec(
224
+ shell,
225
+ stdin=asyncio.subprocess.PIPE,
226
+ stdout=asyncio.subprocess.PIPE,
227
+ stderr=asyncio.subprocess.STDOUT,
228
+ cwd=cwd,
229
+ env=_build_child_env(),
230
+ )
231
+ session = TerminalSession(term_id, proc, channel)
232
+
233
+ self._sessions[term_id] = session
234
+ await session.start_io_forwarding()
235
+
236
+ return {
237
+ "terminal_id": term_id,
238
+ "channel": channel_id,
239
+ "pid": session.proc.pid,
240
+ "shell": shell,
241
+ "cwd": cwd,
242
+ }
243
+
244
+ def get_session(self, terminal_id: str) -> Optional[TerminalSession]:
245
+ """Get a terminal session by ID."""
246
+ return self._sessions.get(terminal_id)
247
+
248
+ def remove_session(self, terminal_id: str) -> Optional[TerminalSession]:
249
+ """Remove and return a terminal session."""
250
+ return self._sessions.pop(terminal_id, None)
251
+
252
+ def list_sessions(self) -> List[Dict[str, Any]]:
253
+ """List all terminal sessions."""
254
+ return [
255
+ {
256
+ "terminal_id": s.id,
257
+ "channel": s.channel.id,
258
+ "pid": s.proc.pid,
259
+ "returncode": s.proc.returncode,
260
+ "buffer": s.snapshot_buffer(),
261
+ }
262
+ for s in self._sessions.values()
263
+ ]
264
+
265
+ def reattach_sessions(self, mux):
266
+ """Reattach sessions to a new multiplexer after reconnection."""
267
+ self.mux = mux
268
+ highest_cid = self._next_channel
269
+
270
+ for sess in self._sessions.values():
271
+ cid = sess.channel.id
272
+ sess.channel = self.mux.get_channel(cid)
273
+ highest_cid = max(highest_cid, cid + 1)
274
+
275
+ self._next_channel = max(self._next_channel, highest_cid)
@@ -0,0 +1,32 @@
1
+ """System command handlers."""
2
+
3
+ import logging
4
+ from pathlib import Path
5
+ from typing import Any, Dict
6
+
7
+ import psutil
8
+
9
+ from .base import SyncHandler
10
+
11
+ logger = logging.getLogger(__name__)
12
+
13
+
14
+ class SystemInfoHandler(SyncHandler):
15
+ """Handler for getting system information."""
16
+
17
+ @property
18
+ def command_name(self) -> str:
19
+ return "system_info"
20
+
21
+ def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
22
+ """Get system information."""
23
+ info = {
24
+ "cpu_percent": psutil.cpu_percent(interval=0.1),
25
+ "memory": psutil.virtual_memory()._asdict(),
26
+ "disk": psutil.disk_usage(str(Path.home()))._asdict(),
27
+ }
28
+
29
+ return {
30
+ "event": "system_info",
31
+ "info": info,
32
+ }