portacode 0.2.3.dev0__tar.gz → 0.2.4.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.
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/PKG-INFO +1 -1
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/_version.py +2 -2
- portacode-0.2.4.dev0/portacode/connection/handlers/README.md +427 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/__init__.py +13 -0
- portacode-0.2.4.dev0/portacode/connection/handlers/file_handlers.py +209 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/session.py +7 -13
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/multiplex.py +7 -7
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/PKG-INFO +1 -1
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/SOURCES.txt +2 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/.gitignore +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/.gitmodules +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/MANIFEST.in +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/Makefile +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/README.md +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/docker-compose.yaml +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/README.md +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/__init__.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/__main__.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/cli.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/README.md +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/__init__.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/client.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/base.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/registry.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/system_handlers.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/terminal_handlers.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/terminal.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/data.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/keypair.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/service.py +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/dependency_links.txt +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/entry_points.txt +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/requires.txt +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode.egg-info/top_level.txt +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/pyproject.toml +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/setup.cfg +0 -0
- {portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/setup.py +0 -0
|
@@ -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.
|
|
21
|
-
__version_tuple__ = version_tuple = (0, 2,
|
|
20
|
+
__version__ = version = '0.2.4.dev'
|
|
21
|
+
__version_tuple__ = version_tuple = (0, 2, 4, 'dev0')
|
|
@@ -0,0 +1,427 @@
|
|
|
1
|
+
# Portacode Handler System
|
|
2
|
+
|
|
3
|
+
This directory contains the modular command handler system for the Portacode client. The system provides a clean, extensible architecture for processing commands from the gateway.
|
|
4
|
+
|
|
5
|
+
## Architecture Overview
|
|
6
|
+
|
|
7
|
+
The handler system consists of:
|
|
8
|
+
|
|
9
|
+
- **Base Handler Classes**: `BaseHandler`, `AsyncHandler`, `SyncHandler`
|
|
10
|
+
- **Command Registry**: `CommandRegistry` for managing and dispatching handlers
|
|
11
|
+
- **Session Management**: `SessionManager` for terminal session lifecycle
|
|
12
|
+
- **Specific Handlers**: Individual command implementations
|
|
13
|
+
|
|
14
|
+
## Existing Handlers
|
|
15
|
+
|
|
16
|
+
### Terminal Handlers (`terminal_handlers.py`)
|
|
17
|
+
|
|
18
|
+
1. **`TerminalStartHandler`** - `terminal_start`
|
|
19
|
+
- Starts new terminal sessions
|
|
20
|
+
- Supports shell and cwd parameters
|
|
21
|
+
- Handles both Windows (ConPTY) and Unix (PTY) systems
|
|
22
|
+
|
|
23
|
+
2. **`TerminalSendHandler`** - `terminal_send`
|
|
24
|
+
- Sends data to existing terminal sessions
|
|
25
|
+
- Requires terminal_id and data parameters
|
|
26
|
+
|
|
27
|
+
3. **`TerminalStopHandler`** - `terminal_stop`
|
|
28
|
+
- Terminates terminal sessions
|
|
29
|
+
- Cleans up resources and sends exit events
|
|
30
|
+
|
|
31
|
+
4. **`TerminalListHandler`** - `terminal_list`
|
|
32
|
+
- Lists all active terminal sessions
|
|
33
|
+
- Returns session metadata and buffer contents
|
|
34
|
+
|
|
35
|
+
### System Handlers (`system_handlers.py`)
|
|
36
|
+
|
|
37
|
+
1. **`SystemInfoHandler`** - `system_info`
|
|
38
|
+
- Provides system resource information
|
|
39
|
+
- Returns CPU, memory, and disk usage data
|
|
40
|
+
|
|
41
|
+
## Adding New Handlers
|
|
42
|
+
|
|
43
|
+
### 1. Asynchronous Handlers
|
|
44
|
+
|
|
45
|
+
For commands that need to perform I/O operations, use `AsyncHandler`:
|
|
46
|
+
|
|
47
|
+
```python
|
|
48
|
+
from .base import AsyncHandler
|
|
49
|
+
from typing import Any, Dict
|
|
50
|
+
|
|
51
|
+
class FileReadHandler(AsyncHandler):
|
|
52
|
+
"""Handler for reading file contents."""
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def command_name(self) -> str:
|
|
56
|
+
return "file_read"
|
|
57
|
+
|
|
58
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
59
|
+
"""Read file contents asynchronously."""
|
|
60
|
+
file_path = message.get("path")
|
|
61
|
+
if not file_path:
|
|
62
|
+
raise ValueError("path parameter is required")
|
|
63
|
+
|
|
64
|
+
# Async file operations
|
|
65
|
+
import aiofiles
|
|
66
|
+
async with aiofiles.open(file_path, 'r') as f:
|
|
67
|
+
content = await f.read()
|
|
68
|
+
|
|
69
|
+
return {
|
|
70
|
+
"event": "file_read_response",
|
|
71
|
+
"path": file_path,
|
|
72
|
+
"content": content,
|
|
73
|
+
}
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
### 2. Synchronous Handlers
|
|
77
|
+
|
|
78
|
+
For CPU-bound operations or simple synchronous tasks, use `SyncHandler`:
|
|
79
|
+
|
|
80
|
+
```python
|
|
81
|
+
from .base import SyncHandler
|
|
82
|
+
from typing import Any, Dict
|
|
83
|
+
import os
|
|
84
|
+
|
|
85
|
+
class DirectoryListHandler(SyncHandler):
|
|
86
|
+
"""Handler for listing directory contents."""
|
|
87
|
+
|
|
88
|
+
@property
|
|
89
|
+
def command_name(self) -> str:
|
|
90
|
+
return "directory_list"
|
|
91
|
+
|
|
92
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
93
|
+
"""List directory contents synchronously."""
|
|
94
|
+
path = message.get("path", ".")
|
|
95
|
+
|
|
96
|
+
try:
|
|
97
|
+
items = []
|
|
98
|
+
for item in os.listdir(path):
|
|
99
|
+
item_path = os.path.join(path, item)
|
|
100
|
+
items.append({
|
|
101
|
+
"name": item,
|
|
102
|
+
"is_dir": os.path.isdir(item_path),
|
|
103
|
+
"size": os.path.getsize(item_path) if os.path.isfile(item_path) else 0,
|
|
104
|
+
})
|
|
105
|
+
|
|
106
|
+
return {
|
|
107
|
+
"event": "directory_list_response",
|
|
108
|
+
"path": path,
|
|
109
|
+
"items": items,
|
|
110
|
+
}
|
|
111
|
+
except Exception as e:
|
|
112
|
+
raise RuntimeError(f"Failed to list directory: {e}")
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
### 3. Complex Handler with Context Access
|
|
116
|
+
|
|
117
|
+
Handlers can access shared context and services:
|
|
118
|
+
|
|
119
|
+
```python
|
|
120
|
+
from .base import AsyncHandler
|
|
121
|
+
from typing import Any, Dict
|
|
122
|
+
|
|
123
|
+
class ProcessManagementHandler(AsyncHandler):
|
|
124
|
+
"""Handler for managing processes on the device."""
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def command_name(self) -> str:
|
|
128
|
+
return "process_management"
|
|
129
|
+
|
|
130
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
131
|
+
"""Manage processes with access to session manager."""
|
|
132
|
+
action = message.get("action") # "list", "kill", "start"
|
|
133
|
+
|
|
134
|
+
# Access session manager from context
|
|
135
|
+
session_manager = self.context.get("session_manager")
|
|
136
|
+
if not session_manager:
|
|
137
|
+
raise RuntimeError("Session manager not available")
|
|
138
|
+
|
|
139
|
+
if action == "list":
|
|
140
|
+
import psutil
|
|
141
|
+
processes = []
|
|
142
|
+
for proc in psutil.process_iter(['pid', 'name', 'cpu_percent']):
|
|
143
|
+
try:
|
|
144
|
+
processes.append(proc.info)
|
|
145
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
146
|
+
continue
|
|
147
|
+
|
|
148
|
+
return {
|
|
149
|
+
"event": "process_list_response",
|
|
150
|
+
"processes": processes,
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
elif action == "kill":
|
|
154
|
+
pid = message.get("pid")
|
|
155
|
+
if not pid:
|
|
156
|
+
raise ValueError("pid parameter required for kill action")
|
|
157
|
+
|
|
158
|
+
import psutil
|
|
159
|
+
try:
|
|
160
|
+
process = psutil.Process(pid)
|
|
161
|
+
process.terminate()
|
|
162
|
+
return {
|
|
163
|
+
"event": "process_killed",
|
|
164
|
+
"pid": pid,
|
|
165
|
+
}
|
|
166
|
+
except psutil.NoSuchProcess:
|
|
167
|
+
raise ValueError(f"Process {pid} not found")
|
|
168
|
+
|
|
169
|
+
else:
|
|
170
|
+
raise ValueError(f"Unknown action: {action}")
|
|
171
|
+
```
|
|
172
|
+
|
|
173
|
+
### 4. Handler with Custom Response Handling
|
|
174
|
+
|
|
175
|
+
You can override the `handle` method for custom response behavior:
|
|
176
|
+
|
|
177
|
+
```python
|
|
178
|
+
from .base import BaseHandler
|
|
179
|
+
from typing import Any, Dict, Optional
|
|
180
|
+
|
|
181
|
+
class StreamingHandler(BaseHandler):
|
|
182
|
+
"""Handler that streams responses."""
|
|
183
|
+
|
|
184
|
+
@property
|
|
185
|
+
def command_name(self) -> str:
|
|
186
|
+
return "streaming_data"
|
|
187
|
+
|
|
188
|
+
async def handle(self, message: Dict[str, Any], reply_channel: Optional[str] = None) -> None:
|
|
189
|
+
"""Handle streaming data with custom response handling."""
|
|
190
|
+
try:
|
|
191
|
+
# Send initial response
|
|
192
|
+
await self.send_response({
|
|
193
|
+
"event": "streaming_started",
|
|
194
|
+
"message": "Starting data stream",
|
|
195
|
+
}, reply_channel)
|
|
196
|
+
|
|
197
|
+
# Stream data
|
|
198
|
+
for i in range(10):
|
|
199
|
+
await asyncio.sleep(0.1)
|
|
200
|
+
await self.send_response({
|
|
201
|
+
"event": "streaming_data",
|
|
202
|
+
"chunk": i,
|
|
203
|
+
"data": f"Data chunk {i}",
|
|
204
|
+
}, reply_channel)
|
|
205
|
+
|
|
206
|
+
# Send completion
|
|
207
|
+
await self.send_response({
|
|
208
|
+
"event": "streaming_completed",
|
|
209
|
+
"message": "Stream completed",
|
|
210
|
+
}, reply_channel)
|
|
211
|
+
|
|
212
|
+
except Exception as exc:
|
|
213
|
+
await self.send_error(str(exc), reply_channel)
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
## Registering Handlers
|
|
217
|
+
|
|
218
|
+
### 1. Automatic Registration
|
|
219
|
+
|
|
220
|
+
Add your handler to the `__init__.py` file and register it in `TerminalManager._register_default_handlers()`:
|
|
221
|
+
|
|
222
|
+
```python
|
|
223
|
+
# In __init__.py
|
|
224
|
+
from .file_handlers import FileReadHandler, DirectoryListHandler
|
|
225
|
+
|
|
226
|
+
# In terminal.py _register_default_handlers()
|
|
227
|
+
def _register_default_handlers(self) -> None:
|
|
228
|
+
"""Register the default command handlers."""
|
|
229
|
+
# ... existing handlers ...
|
|
230
|
+
self._command_registry.register(FileReadHandler)
|
|
231
|
+
self._command_registry.register(DirectoryListHandler)
|
|
232
|
+
```
|
|
233
|
+
|
|
234
|
+
### 2. Dynamic Registration
|
|
235
|
+
|
|
236
|
+
Register handlers at runtime:
|
|
237
|
+
|
|
238
|
+
```python
|
|
239
|
+
# In your code
|
|
240
|
+
terminal_manager.register_handler(MyCustomHandler)
|
|
241
|
+
|
|
242
|
+
# List all registered commands
|
|
243
|
+
commands = terminal_manager.list_commands()
|
|
244
|
+
print(f"Available commands: {commands}")
|
|
245
|
+
|
|
246
|
+
# Unregister if needed
|
|
247
|
+
terminal_manager.unregister_handler("my_command")
|
|
248
|
+
```
|
|
249
|
+
|
|
250
|
+
## Handler Guidelines
|
|
251
|
+
|
|
252
|
+
### Best Practices
|
|
253
|
+
|
|
254
|
+
1. **Command Naming**: Use descriptive, action-oriented names (`file_read`, `system_info`)
|
|
255
|
+
2. **Error Handling**: Always validate input parameters and provide meaningful error messages
|
|
256
|
+
3. **Response Format**: Use consistent response formats with `event` field
|
|
257
|
+
4. **Resource Management**: Clean up resources in exception handlers
|
|
258
|
+
5. **Logging**: Use appropriate logging levels for debugging and monitoring
|
|
259
|
+
|
|
260
|
+
### Parameter Validation
|
|
261
|
+
|
|
262
|
+
```python
|
|
263
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
264
|
+
# Validate required parameters
|
|
265
|
+
required_params = ["param1", "param2"]
|
|
266
|
+
for param in required_params:
|
|
267
|
+
if param not in message:
|
|
268
|
+
raise ValueError(f"Missing required parameter: {param}")
|
|
269
|
+
|
|
270
|
+
# Validate parameter types
|
|
271
|
+
if not isinstance(message.get("count"), int):
|
|
272
|
+
raise ValueError("count parameter must be an integer")
|
|
273
|
+
|
|
274
|
+
# Your logic here...
|
|
275
|
+
```
|
|
276
|
+
|
|
277
|
+
### Error Handling
|
|
278
|
+
|
|
279
|
+
```python
|
|
280
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
281
|
+
try:
|
|
282
|
+
# Your logic here
|
|
283
|
+
result = await some_async_operation()
|
|
284
|
+
return {"event": "success", "result": result}
|
|
285
|
+
except FileNotFoundError:
|
|
286
|
+
raise ValueError("File not found")
|
|
287
|
+
except PermissionError:
|
|
288
|
+
raise RuntimeError("Permission denied")
|
|
289
|
+
except Exception as e:
|
|
290
|
+
raise RuntimeError(f"Unexpected error: {e}")
|
|
291
|
+
```
|
|
292
|
+
|
|
293
|
+
## Testing Handlers
|
|
294
|
+
|
|
295
|
+
### Unit Testing
|
|
296
|
+
|
|
297
|
+
```python
|
|
298
|
+
import pytest
|
|
299
|
+
from unittest.mock import Mock, AsyncMock
|
|
300
|
+
from portacode.connection.handlers.your_handler import YourHandler
|
|
301
|
+
|
|
302
|
+
@pytest.mark.asyncio
|
|
303
|
+
async def test_your_handler():
|
|
304
|
+
# Mock control channel
|
|
305
|
+
control_channel = AsyncMock()
|
|
306
|
+
|
|
307
|
+
# Mock context
|
|
308
|
+
context = {"session_manager": Mock()}
|
|
309
|
+
|
|
310
|
+
# Create handler
|
|
311
|
+
handler = YourHandler(control_channel, context)
|
|
312
|
+
|
|
313
|
+
# Test execute
|
|
314
|
+
message = {"param1": "value1"}
|
|
315
|
+
result = await handler.execute(message)
|
|
316
|
+
|
|
317
|
+
assert result["event"] == "expected_event"
|
|
318
|
+
assert result["data"] == "expected_data"
|
|
319
|
+
```
|
|
320
|
+
|
|
321
|
+
### Integration Testing
|
|
322
|
+
|
|
323
|
+
```python
|
|
324
|
+
# Test with real TerminalManager
|
|
325
|
+
terminal_manager = TerminalManager(mock_multiplexer)
|
|
326
|
+
terminal_manager.register_handler(YourHandler)
|
|
327
|
+
|
|
328
|
+
# Simulate command
|
|
329
|
+
message = {"cmd": "your_command", "param1": "value1"}
|
|
330
|
+
# Process through control loop...
|
|
331
|
+
```
|
|
332
|
+
|
|
333
|
+
## Communication Protocol
|
|
334
|
+
|
|
335
|
+
### Command Format
|
|
336
|
+
|
|
337
|
+
Commands from the gateway follow this format:
|
|
338
|
+
|
|
339
|
+
```json
|
|
340
|
+
{
|
|
341
|
+
"cmd": "command_name",
|
|
342
|
+
"param1": "value1",
|
|
343
|
+
"param2": "value2",
|
|
344
|
+
"reply_channel": "optional_reply_channel"
|
|
345
|
+
}
|
|
346
|
+
```
|
|
347
|
+
|
|
348
|
+
### Response Format
|
|
349
|
+
|
|
350
|
+
Responses to the gateway should follow this format:
|
|
351
|
+
|
|
352
|
+
```json
|
|
353
|
+
{
|
|
354
|
+
"event": "event_name",
|
|
355
|
+
"data": "response_data",
|
|
356
|
+
"reply_channel": "reply_channel_if_provided"
|
|
357
|
+
}
|
|
358
|
+
```
|
|
359
|
+
|
|
360
|
+
### Error Format
|
|
361
|
+
|
|
362
|
+
Error responses:
|
|
363
|
+
|
|
364
|
+
```json
|
|
365
|
+
{
|
|
366
|
+
"event": "error",
|
|
367
|
+
"message": "Error description",
|
|
368
|
+
"reply_channel": "reply_channel_if_provided"
|
|
369
|
+
}
|
|
370
|
+
```
|
|
371
|
+
|
|
372
|
+
## Advanced Topics
|
|
373
|
+
|
|
374
|
+
### Custom Context
|
|
375
|
+
|
|
376
|
+
You can extend the context with custom services:
|
|
377
|
+
|
|
378
|
+
```python
|
|
379
|
+
# In TerminalManager._set_mux()
|
|
380
|
+
self._context = {
|
|
381
|
+
"session_manager": self._session_manager,
|
|
382
|
+
"mux": mux,
|
|
383
|
+
"file_manager": FileManager(),
|
|
384
|
+
"process_manager": ProcessManager(),
|
|
385
|
+
}
|
|
386
|
+
```
|
|
387
|
+
|
|
388
|
+
### Handler Chaining
|
|
389
|
+
|
|
390
|
+
Handlers can call other handlers:
|
|
391
|
+
|
|
392
|
+
```python
|
|
393
|
+
async def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
394
|
+
# Get another handler
|
|
395
|
+
other_handler = self.context.get("command_registry").get_handler("other_command")
|
|
396
|
+
if other_handler:
|
|
397
|
+
intermediate_result = await other_handler.execute(message)
|
|
398
|
+
# Process intermediate result...
|
|
399
|
+
|
|
400
|
+
return final_result
|
|
401
|
+
```
|
|
402
|
+
|
|
403
|
+
### Middleware Pattern
|
|
404
|
+
|
|
405
|
+
Create middleware handlers that wrap other handlers:
|
|
406
|
+
|
|
407
|
+
```python
|
|
408
|
+
class LoggingMiddleware(BaseHandler):
|
|
409
|
+
def __init__(self, control_channel, context, wrapped_handler):
|
|
410
|
+
super().__init__(control_channel, context)
|
|
411
|
+
self.wrapped_handler = wrapped_handler
|
|
412
|
+
|
|
413
|
+
async def handle(self, message, reply_channel=None):
|
|
414
|
+
logger.info(f"Executing command: {message.get('cmd')}")
|
|
415
|
+
start_time = time.time()
|
|
416
|
+
|
|
417
|
+
try:
|
|
418
|
+
result = await self.wrapped_handler.handle(message, reply_channel)
|
|
419
|
+
duration = time.time() - start_time
|
|
420
|
+
logger.info(f"Command completed in {duration:.2f}s")
|
|
421
|
+
return result
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.error(f"Command failed: {e}")
|
|
424
|
+
raise
|
|
425
|
+
```
|
|
426
|
+
|
|
427
|
+
This modular system provides a clean, extensible architecture for adding new functionality to the Portacode client while maintaining backward compatibility and code organization.
|
|
@@ -14,6 +14,13 @@ from .terminal_handlers import (
|
|
|
14
14
|
TerminalListHandler,
|
|
15
15
|
)
|
|
16
16
|
from .system_handlers import SystemInfoHandler
|
|
17
|
+
from .file_handlers import (
|
|
18
|
+
FileReadHandler,
|
|
19
|
+
FileWriteHandler,
|
|
20
|
+
DirectoryListHandler,
|
|
21
|
+
FileInfoHandler,
|
|
22
|
+
FileDeleteHandler,
|
|
23
|
+
)
|
|
17
24
|
|
|
18
25
|
__all__ = [
|
|
19
26
|
"BaseHandler",
|
|
@@ -25,4 +32,10 @@ __all__ = [
|
|
|
25
32
|
"TerminalStopHandler",
|
|
26
33
|
"TerminalListHandler",
|
|
27
34
|
"SystemInfoHandler",
|
|
35
|
+
# File operation handlers (optional - register as needed)
|
|
36
|
+
"FileReadHandler",
|
|
37
|
+
"FileWriteHandler",
|
|
38
|
+
"DirectoryListHandler",
|
|
39
|
+
"FileInfoHandler",
|
|
40
|
+
"FileDeleteHandler",
|
|
28
41
|
]
|
|
@@ -0,0 +1,209 @@
|
|
|
1
|
+
"""File operation handlers for demonstrating the send_command functionality."""
|
|
2
|
+
|
|
3
|
+
import os
|
|
4
|
+
import logging
|
|
5
|
+
from typing import Any, Dict
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
|
|
8
|
+
from .base import AsyncHandler, SyncHandler
|
|
9
|
+
|
|
10
|
+
logger = logging.getLogger(__name__)
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class FileReadHandler(SyncHandler):
|
|
14
|
+
"""Handler for reading file contents."""
|
|
15
|
+
|
|
16
|
+
@property
|
|
17
|
+
def command_name(self) -> str:
|
|
18
|
+
return "file_read"
|
|
19
|
+
|
|
20
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
21
|
+
"""Read file contents."""
|
|
22
|
+
file_path = message.get("path")
|
|
23
|
+
if not file_path:
|
|
24
|
+
raise ValueError("path parameter is required")
|
|
25
|
+
|
|
26
|
+
try:
|
|
27
|
+
with open(file_path, 'r', encoding='utf-8') as f:
|
|
28
|
+
content = f.read()
|
|
29
|
+
|
|
30
|
+
return {
|
|
31
|
+
"event": "file_read_response",
|
|
32
|
+
"path": file_path,
|
|
33
|
+
"content": content,
|
|
34
|
+
"size": len(content),
|
|
35
|
+
}
|
|
36
|
+
except FileNotFoundError:
|
|
37
|
+
raise ValueError(f"File not found: {file_path}")
|
|
38
|
+
except PermissionError:
|
|
39
|
+
raise RuntimeError(f"Permission denied: {file_path}")
|
|
40
|
+
except UnicodeDecodeError:
|
|
41
|
+
raise RuntimeError(f"File is not text or uses unsupported encoding: {file_path}")
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class FileWriteHandler(SyncHandler):
|
|
45
|
+
"""Handler for writing file contents."""
|
|
46
|
+
|
|
47
|
+
@property
|
|
48
|
+
def command_name(self) -> str:
|
|
49
|
+
return "file_write"
|
|
50
|
+
|
|
51
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
52
|
+
"""Write file contents."""
|
|
53
|
+
file_path = message.get("path")
|
|
54
|
+
content = message.get("content", "")
|
|
55
|
+
|
|
56
|
+
if not file_path:
|
|
57
|
+
raise ValueError("path parameter is required")
|
|
58
|
+
|
|
59
|
+
try:
|
|
60
|
+
# Create parent directories if they don't exist
|
|
61
|
+
Path(file_path).parent.mkdir(parents=True, exist_ok=True)
|
|
62
|
+
|
|
63
|
+
with open(file_path, 'w', encoding='utf-8') as f:
|
|
64
|
+
f.write(content)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
"event": "file_write_response",
|
|
68
|
+
"path": file_path,
|
|
69
|
+
"bytes_written": len(content.encode('utf-8')),
|
|
70
|
+
"success": True,
|
|
71
|
+
}
|
|
72
|
+
except PermissionError:
|
|
73
|
+
raise RuntimeError(f"Permission denied: {file_path}")
|
|
74
|
+
except OSError as e:
|
|
75
|
+
raise RuntimeError(f"Failed to write file: {e}")
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class DirectoryListHandler(SyncHandler):
|
|
79
|
+
"""Handler for listing directory contents."""
|
|
80
|
+
|
|
81
|
+
@property
|
|
82
|
+
def command_name(self) -> str:
|
|
83
|
+
return "directory_list"
|
|
84
|
+
|
|
85
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
86
|
+
"""List directory contents."""
|
|
87
|
+
path = message.get("path", ".")
|
|
88
|
+
show_hidden = message.get("show_hidden", False)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
items = []
|
|
92
|
+
for item in os.listdir(path):
|
|
93
|
+
# Skip hidden files unless requested
|
|
94
|
+
if not show_hidden and item.startswith('.'):
|
|
95
|
+
continue
|
|
96
|
+
|
|
97
|
+
item_path = os.path.join(path, item)
|
|
98
|
+
try:
|
|
99
|
+
stat_info = os.stat(item_path)
|
|
100
|
+
items.append({
|
|
101
|
+
"name": item,
|
|
102
|
+
"is_dir": os.path.isdir(item_path),
|
|
103
|
+
"is_file": os.path.isfile(item_path),
|
|
104
|
+
"size": stat_info.st_size,
|
|
105
|
+
"modified": stat_info.st_mtime,
|
|
106
|
+
"permissions": oct(stat_info.st_mode)[-3:],
|
|
107
|
+
})
|
|
108
|
+
except (OSError, PermissionError):
|
|
109
|
+
# Skip items we can't stat
|
|
110
|
+
continue
|
|
111
|
+
|
|
112
|
+
return {
|
|
113
|
+
"event": "directory_list_response",
|
|
114
|
+
"path": path,
|
|
115
|
+
"items": items,
|
|
116
|
+
"count": len(items),
|
|
117
|
+
}
|
|
118
|
+
except FileNotFoundError:
|
|
119
|
+
raise ValueError(f"Directory not found: {path}")
|
|
120
|
+
except PermissionError:
|
|
121
|
+
raise RuntimeError(f"Permission denied: {path}")
|
|
122
|
+
except NotADirectoryError:
|
|
123
|
+
raise ValueError(f"Path is not a directory: {path}")
|
|
124
|
+
|
|
125
|
+
|
|
126
|
+
class FileInfoHandler(SyncHandler):
|
|
127
|
+
"""Handler for getting file/directory information."""
|
|
128
|
+
|
|
129
|
+
@property
|
|
130
|
+
def command_name(self) -> str:
|
|
131
|
+
return "file_info"
|
|
132
|
+
|
|
133
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
134
|
+
"""Get file or directory information."""
|
|
135
|
+
path = message.get("path")
|
|
136
|
+
if not path:
|
|
137
|
+
raise ValueError("path parameter is required")
|
|
138
|
+
|
|
139
|
+
try:
|
|
140
|
+
stat_info = os.stat(path)
|
|
141
|
+
|
|
142
|
+
return {
|
|
143
|
+
"event": "file_info_response",
|
|
144
|
+
"path": path,
|
|
145
|
+
"exists": True,
|
|
146
|
+
"is_file": os.path.isfile(path),
|
|
147
|
+
"is_dir": os.path.isdir(path),
|
|
148
|
+
"is_symlink": os.path.islink(path),
|
|
149
|
+
"size": stat_info.st_size,
|
|
150
|
+
"modified": stat_info.st_mtime,
|
|
151
|
+
"accessed": stat_info.st_atime,
|
|
152
|
+
"created": stat_info.st_ctime,
|
|
153
|
+
"permissions": oct(stat_info.st_mode)[-3:],
|
|
154
|
+
"owner_uid": stat_info.st_uid,
|
|
155
|
+
"group_gid": stat_info.st_gid,
|
|
156
|
+
}
|
|
157
|
+
except FileNotFoundError:
|
|
158
|
+
return {
|
|
159
|
+
"event": "file_info_response",
|
|
160
|
+
"path": path,
|
|
161
|
+
"exists": False,
|
|
162
|
+
}
|
|
163
|
+
except PermissionError:
|
|
164
|
+
raise RuntimeError(f"Permission denied: {path}")
|
|
165
|
+
|
|
166
|
+
|
|
167
|
+
class FileDeleteHandler(SyncHandler):
|
|
168
|
+
"""Handler for deleting files and directories."""
|
|
169
|
+
|
|
170
|
+
@property
|
|
171
|
+
def command_name(self) -> str:
|
|
172
|
+
return "file_delete"
|
|
173
|
+
|
|
174
|
+
def execute(self, message: Dict[str, Any]) -> Dict[str, Any]:
|
|
175
|
+
"""Delete a file or directory."""
|
|
176
|
+
path = message.get("path")
|
|
177
|
+
recursive = message.get("recursive", False)
|
|
178
|
+
|
|
179
|
+
if not path:
|
|
180
|
+
raise ValueError("path parameter is required")
|
|
181
|
+
|
|
182
|
+
try:
|
|
183
|
+
if os.path.isfile(path):
|
|
184
|
+
os.remove(path)
|
|
185
|
+
deleted_type = "file"
|
|
186
|
+
elif os.path.isdir(path):
|
|
187
|
+
if recursive:
|
|
188
|
+
import shutil
|
|
189
|
+
shutil.rmtree(path)
|
|
190
|
+
else:
|
|
191
|
+
os.rmdir(path)
|
|
192
|
+
deleted_type = "directory"
|
|
193
|
+
else:
|
|
194
|
+
raise ValueError(f"Path does not exist: {path}")
|
|
195
|
+
|
|
196
|
+
return {
|
|
197
|
+
"event": "file_delete_response",
|
|
198
|
+
"path": path,
|
|
199
|
+
"deleted_type": deleted_type,
|
|
200
|
+
"success": True,
|
|
201
|
+
}
|
|
202
|
+
except FileNotFoundError:
|
|
203
|
+
raise ValueError(f"Path not found: {path}")
|
|
204
|
+
except PermissionError:
|
|
205
|
+
raise RuntimeError(f"Permission denied: {path}")
|
|
206
|
+
except OSError as e:
|
|
207
|
+
if "Directory not empty" in str(e):
|
|
208
|
+
raise ValueError(f"Directory not empty (use recursive=True): {path}")
|
|
209
|
+
raise RuntimeError(f"Failed to delete: {e}")
|
|
@@ -164,13 +164,10 @@ class SessionManager:
|
|
|
164
164
|
def __init__(self, mux):
|
|
165
165
|
self.mux = mux
|
|
166
166
|
self._sessions: Dict[str, TerminalSession] = {}
|
|
167
|
-
self._next_channel = 100
|
|
168
167
|
|
|
169
|
-
def _allocate_channel_id(self) ->
|
|
170
|
-
"""Allocate a new channel ID for a terminal session."""
|
|
171
|
-
|
|
172
|
-
self._next_channel += 1
|
|
173
|
-
return cid
|
|
168
|
+
def _allocate_channel_id(self) -> str:
|
|
169
|
+
"""Allocate a new unique channel ID for a terminal session using UUID."""
|
|
170
|
+
return uuid.uuid4().hex
|
|
174
171
|
|
|
175
172
|
async def create_session(self, shell: Optional[str] = None, cwd: Optional[str] = None) -> Dict[str, Any]:
|
|
176
173
|
"""Create a new terminal session."""
|
|
@@ -182,7 +179,7 @@ class SessionManager:
|
|
|
182
179
|
if shell is None:
|
|
183
180
|
shell = os.getenv("SHELL") if not _IS_WINDOWS else os.getenv("COMSPEC", "cmd.exe")
|
|
184
181
|
|
|
185
|
-
logger.info("Launching terminal %s using shell=%s on channel=%
|
|
182
|
+
logger.info("Launching terminal %s using shell=%s on channel=%s", term_id, shell, channel_id)
|
|
186
183
|
|
|
187
184
|
if _IS_WINDOWS:
|
|
188
185
|
try:
|
|
@@ -265,11 +262,8 @@ class SessionManager:
|
|
|
265
262
|
def reattach_sessions(self, mux):
|
|
266
263
|
"""Reattach sessions to a new multiplexer after reconnection."""
|
|
267
264
|
self.mux = mux
|
|
268
|
-
highest_cid = self._next_channel
|
|
269
265
|
|
|
270
266
|
for sess in self._sessions.values():
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
|
|
275
|
-
self._next_channel = max(self._next_channel, highest_cid)
|
|
267
|
+
# Get the existing channel ID (now UUID string)
|
|
268
|
+
channel_id = sess.channel.id
|
|
269
|
+
sess.channel = self.mux.get_channel(channel_id)
|
|
@@ -4,7 +4,7 @@ import asyncio
|
|
|
4
4
|
import json
|
|
5
5
|
import logging
|
|
6
6
|
from asyncio import Queue
|
|
7
|
-
from typing import Any, Dict
|
|
7
|
+
from typing import Any, Dict, Union
|
|
8
8
|
|
|
9
9
|
__all__ = ["Multiplexer", "Channel"]
|
|
10
10
|
|
|
@@ -14,7 +14,7 @@ logger = logging.getLogger(__name__)
|
|
|
14
14
|
class Channel:
|
|
15
15
|
"""Represents a virtual duplex channel over a single WebSocket connection."""
|
|
16
16
|
|
|
17
|
-
def __init__(self, channel_id: int, multiplexer: "Multiplexer"):
|
|
17
|
+
def __init__(self, channel_id: Union[int, str], multiplexer: "Multiplexer"):
|
|
18
18
|
self.id = channel_id
|
|
19
19
|
self._mux = multiplexer
|
|
20
20
|
self._incoming: "Queue[Any]" = asyncio.Queue()
|
|
@@ -35,27 +35,27 @@ class Multiplexer:
|
|
|
35
35
|
|
|
36
36
|
Messages exchanged over the WebSocket are JSON objects with two keys:
|
|
37
37
|
|
|
38
|
-
* ``channel`` – integer channel id.
|
|
38
|
+
* ``channel`` – integer or string channel id.
|
|
39
39
|
* ``payload`` – arbitrary JSON-serialisable object.
|
|
40
40
|
"""
|
|
41
41
|
|
|
42
42
|
def __init__(self, send_func):
|
|
43
43
|
self._send_func = send_func # async function (str) -> None
|
|
44
|
-
self._channels: Dict[int, Channel] = {}
|
|
44
|
+
self._channels: Dict[Union[int, str], Channel] = {}
|
|
45
45
|
|
|
46
|
-
def get_channel(self, channel_id: int) -> Channel:
|
|
46
|
+
def get_channel(self, channel_id: Union[int, str]) -> Channel:
|
|
47
47
|
if channel_id not in self._channels:
|
|
48
48
|
self._channels[channel_id] = Channel(channel_id, self)
|
|
49
49
|
return self._channels[channel_id]
|
|
50
50
|
|
|
51
|
-
async def _send_on_channel(self, channel_id: int, payload: Any) -> None:
|
|
51
|
+
async def _send_on_channel(self, channel_id: Union[int, str], payload: Any) -> None:
|
|
52
52
|
frame = json.dumps({"channel": channel_id, "payload": payload})
|
|
53
53
|
await self._send_func(frame)
|
|
54
54
|
|
|
55
55
|
async def on_raw_message(self, raw: str) -> None:
|
|
56
56
|
try:
|
|
57
57
|
data = json.loads(raw)
|
|
58
|
-
channel_id =
|
|
58
|
+
channel_id = data["channel"] # Can be int or str now
|
|
59
59
|
payload = data.get("payload")
|
|
60
60
|
except (ValueError, KeyError) as exc:
|
|
61
61
|
logger.warning("Discarding malformed frame: %s", exc)
|
|
@@ -25,8 +25,10 @@ portacode/connection/__init__.py
|
|
|
25
25
|
portacode/connection/client.py
|
|
26
26
|
portacode/connection/multiplex.py
|
|
27
27
|
portacode/connection/terminal.py
|
|
28
|
+
portacode/connection/handlers/README.md
|
|
28
29
|
portacode/connection/handlers/__init__.py
|
|
29
30
|
portacode/connection/handlers/base.py
|
|
31
|
+
portacode/connection/handlers/file_handlers.py
|
|
30
32
|
portacode/connection/handlers/registry.py
|
|
31
33
|
portacode/connection/handlers/session.py
|
|
32
34
|
portacode/connection/handlers/system_handlers.py
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
{portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/system_handlers.py
RENAMED
|
File without changes
|
{portacode-0.2.3.dev0 → portacode-0.2.4.dev0}/portacode/connection/handlers/terminal_handlers.py
RENAMED
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|