fid-mcp 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Potentially problematic release.
This version of fid-mcp might be problematic. Click here for more details.
- fid_mcp/__init__.py +1 -0
- fid_mcp/config.py +233 -0
- fid_mcp/server.py +474 -0
- fid_mcp/shell.py +745 -0
- fid_mcp-0.1.0.dist-info/METADATA +28 -0
- fid_mcp-0.1.0.dist-info/RECORD +9 -0
- fid_mcp-0.1.0.dist-info/WHEEL +4 -0
- fid_mcp-0.1.0.dist-info/entry_points.txt +2 -0
- fid_mcp-0.1.0.dist-info/licenses/LICENSE +6 -0
fid_mcp/shell.py
ADDED
|
@@ -0,0 +1,745 @@
|
|
|
1
|
+
#!/usr/bin/env python3
|
|
2
|
+
"""
|
|
3
|
+
Interactive Shell Function
|
|
4
|
+
|
|
5
|
+
Provides a generalized interactive shell interface using pexpect for
|
|
6
|
+
managing long-running shell sessions and executing commands with
|
|
7
|
+
real-time output handling.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import os
|
|
12
|
+
import re
|
|
13
|
+
import time
|
|
14
|
+
from typing import Dict, Any, Optional, List, Union
|
|
15
|
+
import pexpect
|
|
16
|
+
|
|
17
|
+
logger = logging.getLogger(__name__)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class InteractiveShell:
|
|
21
|
+
"""Manages an interactive shell session using pexpect"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
shell_cmd: str = "bash",
|
|
26
|
+
shell_args: List[str] = None,
|
|
27
|
+
cwd: str = None,
|
|
28
|
+
timeout: int = 300,
|
|
29
|
+
prompt_pattern: str = None,
|
|
30
|
+
custom_prompt: str = None,
|
|
31
|
+
encoding: str = "utf-8",
|
|
32
|
+
):
|
|
33
|
+
"""
|
|
34
|
+
Initialize an interactive shell session
|
|
35
|
+
|
|
36
|
+
Args:
|
|
37
|
+
shell_cmd: Command to start the shell (default: "bash")
|
|
38
|
+
shell_args: Arguments for the shell command
|
|
39
|
+
cwd: Working directory for the shell
|
|
40
|
+
timeout: Default timeout for operations in seconds
|
|
41
|
+
prompt_pattern: Regex pattern to match shell prompts
|
|
42
|
+
custom_prompt: Custom prompt to set (helps avoid ANSI sequences)
|
|
43
|
+
encoding: Text encoding for the shell
|
|
44
|
+
"""
|
|
45
|
+
self.shell_cmd = shell_cmd
|
|
46
|
+
self.shell_args = shell_args or ["--norc", "--noprofile"]
|
|
47
|
+
self.cwd = cwd or os.getcwd()
|
|
48
|
+
self.timeout = timeout
|
|
49
|
+
self.prompt_pattern = prompt_pattern or [r"\$", r"#", r">", r"nsh>", r"px4>"]
|
|
50
|
+
self.custom_prompt = custom_prompt
|
|
51
|
+
self.encoding = encoding
|
|
52
|
+
|
|
53
|
+
self.process = None
|
|
54
|
+
self.is_active = False
|
|
55
|
+
self.created_at = time.time()
|
|
56
|
+
self.command_history = []
|
|
57
|
+
|
|
58
|
+
def start(self) -> Dict[str, Any]:
|
|
59
|
+
"""Start the interactive shell session"""
|
|
60
|
+
try:
|
|
61
|
+
# Start the shell process
|
|
62
|
+
self.process = pexpect.spawn(
|
|
63
|
+
self.shell_cmd,
|
|
64
|
+
self.shell_args,
|
|
65
|
+
cwd=self.cwd,
|
|
66
|
+
timeout=self.timeout,
|
|
67
|
+
encoding=self.encoding,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
# Wait for initial shell prompt
|
|
71
|
+
initial_timeout = 10
|
|
72
|
+
try:
|
|
73
|
+
self.process.expect(self.prompt_pattern, timeout=initial_timeout)
|
|
74
|
+
except pexpect.TIMEOUT:
|
|
75
|
+
output = self.process.before or ""
|
|
76
|
+
return {
|
|
77
|
+
"success": False,
|
|
78
|
+
"error": f"Timeout waiting for shell prompt after {initial_timeout}s",
|
|
79
|
+
"output": output,
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
# Set custom prompt if specified
|
|
83
|
+
if self.custom_prompt:
|
|
84
|
+
self._set_custom_prompt()
|
|
85
|
+
|
|
86
|
+
# Clear any remaining output
|
|
87
|
+
self._clear_buffer()
|
|
88
|
+
|
|
89
|
+
self.is_active = True
|
|
90
|
+
logger.info(f"Started interactive shell: {self.shell_cmd} in {self.cwd}")
|
|
91
|
+
|
|
92
|
+
return {
|
|
93
|
+
"success": True,
|
|
94
|
+
"shell_cmd": self.shell_cmd,
|
|
95
|
+
"cwd": self.cwd,
|
|
96
|
+
"message": "Interactive shell started successfully",
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
except Exception as e:
|
|
100
|
+
logger.error(f"Failed to start shell: {e}")
|
|
101
|
+
self.is_active = False
|
|
102
|
+
return {
|
|
103
|
+
"success": False,
|
|
104
|
+
"error": f"Shell startup failed: {str(e)}",
|
|
105
|
+
"shell_cmd": self.shell_cmd,
|
|
106
|
+
"shell_args": self.shell_args,
|
|
107
|
+
"cwd": self.cwd,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
def execute_command(
|
|
111
|
+
self,
|
|
112
|
+
command: str,
|
|
113
|
+
wait: int = 0,
|
|
114
|
+
expect_patterns: List[str] = None,
|
|
115
|
+
capture_output: bool = True,
|
|
116
|
+
continue_on_error: bool = False,
|
|
117
|
+
) -> Dict[str, Any]:
|
|
118
|
+
"""
|
|
119
|
+
Execute a command in the interactive shell
|
|
120
|
+
|
|
121
|
+
Args:
|
|
122
|
+
command: Command to execute
|
|
123
|
+
wait: Wait time in seconds. If 0, complete as soon as prompt appears.
|
|
124
|
+
If > 0, always wait this long and return output (ignore prompts)
|
|
125
|
+
expect_patterns: Additional patterns to expect (beyond prompt)
|
|
126
|
+
capture_output: Whether to capture and return output
|
|
127
|
+
continue_on_error: Whether to continue if command fails
|
|
128
|
+
|
|
129
|
+
Returns:
|
|
130
|
+
Dictionary with execution results
|
|
131
|
+
"""
|
|
132
|
+
if not self.is_active or not self.process:
|
|
133
|
+
return {
|
|
134
|
+
"success": False,
|
|
135
|
+
"error": "Shell session is not active",
|
|
136
|
+
"command": command,
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
command_start_time = time.time()
|
|
140
|
+
|
|
141
|
+
try:
|
|
142
|
+
# Clear buffer before sending command
|
|
143
|
+
self._clear_buffer()
|
|
144
|
+
|
|
145
|
+
# Send the command
|
|
146
|
+
logger.info(f"Executing command: {command}")
|
|
147
|
+
self.process.sendline(command)
|
|
148
|
+
|
|
149
|
+
# If wait > 0, use simple time-based approach
|
|
150
|
+
if wait > 0:
|
|
151
|
+
return self._execute_with_fixed_wait(
|
|
152
|
+
command, wait, capture_output, command_start_time
|
|
153
|
+
)
|
|
154
|
+
|
|
155
|
+
# Determine what patterns to expect
|
|
156
|
+
expect_patterns = expect_patterns or []
|
|
157
|
+
all_patterns = (
|
|
158
|
+
self.prompt_pattern + expect_patterns + [pexpect.TIMEOUT, pexpect.EOF]
|
|
159
|
+
)
|
|
160
|
+
|
|
161
|
+
# For wait=0, wait for prompt to appear
|
|
162
|
+
return self._execute_with_prompt_wait(
|
|
163
|
+
command, expect_patterns, capture_output, command_start_time
|
|
164
|
+
)
|
|
165
|
+
|
|
166
|
+
except Exception as e:
|
|
167
|
+
logger.error(f"Error executing command '{command}': {e}")
|
|
168
|
+
duration = time.time() - command_start_time
|
|
169
|
+
return {
|
|
170
|
+
"success": False,
|
|
171
|
+
"error": f"Command execution failed: {str(e)}",
|
|
172
|
+
"command": command,
|
|
173
|
+
"duration": duration,
|
|
174
|
+
"session_active": self.is_active,
|
|
175
|
+
"process_alive": self.process.isalive() if self.process else False,
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
def _execute_with_fixed_wait(
|
|
179
|
+
self, command: str, wait: int, capture_output: bool, command_start_time: float
|
|
180
|
+
) -> Dict[str, Any]:
|
|
181
|
+
"""Execute command and wait for fixed time, ignoring prompts"""
|
|
182
|
+
logger.info(f"Waiting {wait} seconds for command to complete")
|
|
183
|
+
|
|
184
|
+
all_output = ""
|
|
185
|
+
|
|
186
|
+
# Collect output for the specified wait time
|
|
187
|
+
end_time = command_start_time + wait
|
|
188
|
+
while time.time() < end_time:
|
|
189
|
+
try:
|
|
190
|
+
remaining = end_time - time.time()
|
|
191
|
+
chunk = self.process.read_nonblocking(
|
|
192
|
+
size=1000, timeout=min(remaining, 1)
|
|
193
|
+
)
|
|
194
|
+
if capture_output and chunk:
|
|
195
|
+
all_output += chunk
|
|
196
|
+
except pexpect.TIMEOUT:
|
|
197
|
+
continue # Keep waiting
|
|
198
|
+
except pexpect.EOF:
|
|
199
|
+
break
|
|
200
|
+
|
|
201
|
+
# Clean up output
|
|
202
|
+
clean_output = self._clean_output(all_output, command) if capture_output else ""
|
|
203
|
+
duration = time.time() - command_start_time
|
|
204
|
+
|
|
205
|
+
# Always consider fixed-wait commands successful
|
|
206
|
+
result = {
|
|
207
|
+
"success": True,
|
|
208
|
+
"command": command,
|
|
209
|
+
"output": clean_output,
|
|
210
|
+
"duration": duration,
|
|
211
|
+
"cwd": self.cwd,
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
self.command_history.append(result)
|
|
215
|
+
return result
|
|
216
|
+
|
|
217
|
+
def _execute_with_prompt_wait(
|
|
218
|
+
self,
|
|
219
|
+
command: str,
|
|
220
|
+
expect_patterns: List[str],
|
|
221
|
+
capture_output: bool,
|
|
222
|
+
command_start_time: float,
|
|
223
|
+
) -> Dict[str, Any]:
|
|
224
|
+
"""Execute command and wait for prompt to appear"""
|
|
225
|
+
all_patterns = (
|
|
226
|
+
self.prompt_pattern
|
|
227
|
+
+ (expect_patterns or [])
|
|
228
|
+
+ [pexpect.TIMEOUT, pexpect.EOF]
|
|
229
|
+
)
|
|
230
|
+
all_output = ""
|
|
231
|
+
command_completed = False
|
|
232
|
+
|
|
233
|
+
# Use a reasonable default timeout for prompt detection
|
|
234
|
+
max_wait = 60 # 60 seconds max
|
|
235
|
+
|
|
236
|
+
while not command_completed:
|
|
237
|
+
try:
|
|
238
|
+
elapsed = time.time() - command_start_time
|
|
239
|
+
if elapsed > max_wait:
|
|
240
|
+
logger.warning(f"Command '{command}' exceeded maximum wait time")
|
|
241
|
+
break
|
|
242
|
+
|
|
243
|
+
# Wait for patterns
|
|
244
|
+
index = self.process.expect(all_patterns, timeout=5)
|
|
245
|
+
|
|
246
|
+
# Collect output
|
|
247
|
+
if capture_output and self.process.before:
|
|
248
|
+
all_output += self.process.before
|
|
249
|
+
|
|
250
|
+
# Check what we matched
|
|
251
|
+
if index < len(self.prompt_pattern):
|
|
252
|
+
# Found a prompt - command is done
|
|
253
|
+
if self._is_real_prompt_match(
|
|
254
|
+
all_output, self.prompt_pattern[index]
|
|
255
|
+
):
|
|
256
|
+
command_completed = True
|
|
257
|
+
logger.info(f"Command completed after {elapsed:.2f}s")
|
|
258
|
+
|
|
259
|
+
elif index < len(self.prompt_pattern) + len(expect_patterns or []):
|
|
260
|
+
# Matched a custom pattern - continue reading
|
|
261
|
+
continue
|
|
262
|
+
|
|
263
|
+
elif index >= len(all_patterns) - 2: # TIMEOUT or EOF
|
|
264
|
+
# Continue unless we've exceeded max wait
|
|
265
|
+
continue
|
|
266
|
+
|
|
267
|
+
except pexpect.TIMEOUT:
|
|
268
|
+
continue
|
|
269
|
+
except pexpect.EOF:
|
|
270
|
+
if self.process.before and capture_output:
|
|
271
|
+
all_output += self.process.before
|
|
272
|
+
break
|
|
273
|
+
|
|
274
|
+
# Clean up output and determine success
|
|
275
|
+
clean_output = self._clean_output(all_output, command) if capture_output else ""
|
|
276
|
+
success = self._check_command_success(clean_output, command, False)
|
|
277
|
+
duration = time.time() - command_start_time
|
|
278
|
+
|
|
279
|
+
# Store result
|
|
280
|
+
result = {
|
|
281
|
+
"success": success,
|
|
282
|
+
"command": command,
|
|
283
|
+
"output": clean_output,
|
|
284
|
+
"duration": duration,
|
|
285
|
+
"cwd": self.cwd,
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
# Add error details if command failed
|
|
289
|
+
if not success:
|
|
290
|
+
error_info = self._extract_error_details(clean_output, command)
|
|
291
|
+
result["error"] = error_info
|
|
292
|
+
|
|
293
|
+
self.command_history.append(result)
|
|
294
|
+
return result
|
|
295
|
+
|
|
296
|
+
def send_input(self, text: str) -> Dict[str, Any]:
|
|
297
|
+
"""Send input to the interactive shell without expecting a prompt"""
|
|
298
|
+
if not self.is_active or not self.process:
|
|
299
|
+
return {"success": False, "error": "Shell session is not active"}
|
|
300
|
+
|
|
301
|
+
try:
|
|
302
|
+
self.process.send(text)
|
|
303
|
+
return {"success": True, "message": f"Sent input: {repr(text)}"}
|
|
304
|
+
except Exception as e:
|
|
305
|
+
return {"success": False, "error": str(e)}
|
|
306
|
+
|
|
307
|
+
def read_output(self, timeout: int = 5) -> Dict[str, Any]:
|
|
308
|
+
"""Read available output from the shell"""
|
|
309
|
+
if not self.is_active or not self.process:
|
|
310
|
+
return {"success": False, "error": "Shell session is not active"}
|
|
311
|
+
|
|
312
|
+
try:
|
|
313
|
+
output = ""
|
|
314
|
+
try:
|
|
315
|
+
while True:
|
|
316
|
+
chunk = self.process.read_nonblocking(size=1000, timeout=timeout)
|
|
317
|
+
if not chunk:
|
|
318
|
+
break
|
|
319
|
+
output += chunk
|
|
320
|
+
timeout = 0.1 # Reduce timeout for subsequent reads
|
|
321
|
+
except pexpect.TIMEOUT:
|
|
322
|
+
pass # Normal - no more data available
|
|
323
|
+
|
|
324
|
+
return {"success": True, "output": output}
|
|
325
|
+
|
|
326
|
+
except Exception as e:
|
|
327
|
+
return {"success": False, "error": str(e)}
|
|
328
|
+
|
|
329
|
+
def close(self) -> Dict[str, Any]:
|
|
330
|
+
"""Close the interactive shell session and kill any child processes"""
|
|
331
|
+
try:
|
|
332
|
+
if self.process and self.is_active:
|
|
333
|
+
# Send exit command to shell first
|
|
334
|
+
try:
|
|
335
|
+
self.process.sendline("exit")
|
|
336
|
+
self.process.expect(pexpect.EOF, timeout=5)
|
|
337
|
+
except (pexpect.TIMEOUT, pexpect.EOF):
|
|
338
|
+
pass # Shell may have already exited
|
|
339
|
+
|
|
340
|
+
# Terminate the process and its children
|
|
341
|
+
if self.process.isalive():
|
|
342
|
+
self.process.terminate()
|
|
343
|
+
try:
|
|
344
|
+
self.process.wait(timeout=5)
|
|
345
|
+
except pexpect.TIMEOUT:
|
|
346
|
+
# Force kill if it doesn't terminate gracefully
|
|
347
|
+
self.process.kill()
|
|
348
|
+
|
|
349
|
+
# Kill any remaining child processes (like gazebo)
|
|
350
|
+
self._kill_child_processes()
|
|
351
|
+
|
|
352
|
+
self.process.close()
|
|
353
|
+
self.is_active = False
|
|
354
|
+
logger.info(
|
|
355
|
+
"Closed interactive shell session and cleaned up child processes"
|
|
356
|
+
)
|
|
357
|
+
return {
|
|
358
|
+
"success": True,
|
|
359
|
+
"message": "Shell session closed and child processes terminated",
|
|
360
|
+
}
|
|
361
|
+
return {"success": True, "message": "Shell session was already closed"}
|
|
362
|
+
except Exception as e:
|
|
363
|
+
logger.error(f"Error closing shell session: {e}")
|
|
364
|
+
return {"success": False, "error": str(e)}
|
|
365
|
+
|
|
366
|
+
def _kill_child_processes(self):
|
|
367
|
+
"""Kill child processes spawned by the shell"""
|
|
368
|
+
try:
|
|
369
|
+
import psutil
|
|
370
|
+
import signal
|
|
371
|
+
|
|
372
|
+
if not self.process or not hasattr(self.process, "pid"):
|
|
373
|
+
return
|
|
374
|
+
|
|
375
|
+
try:
|
|
376
|
+
parent = psutil.Process(self.process.pid)
|
|
377
|
+
children = parent.children(recursive=True)
|
|
378
|
+
|
|
379
|
+
# First try to terminate children gracefully
|
|
380
|
+
for child in children:
|
|
381
|
+
try:
|
|
382
|
+
child.terminate()
|
|
383
|
+
logger.info(
|
|
384
|
+
f"Terminated child process: {child.pid} ({child.name()})"
|
|
385
|
+
)
|
|
386
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
387
|
+
pass
|
|
388
|
+
|
|
389
|
+
# Wait a bit for graceful termination
|
|
390
|
+
import time
|
|
391
|
+
|
|
392
|
+
time.sleep(2)
|
|
393
|
+
|
|
394
|
+
# Force kill any remaining children
|
|
395
|
+
for child in children:
|
|
396
|
+
try:
|
|
397
|
+
if child.is_running():
|
|
398
|
+
child.kill()
|
|
399
|
+
logger.info(
|
|
400
|
+
f"Force killed child process: {child.pid} ({child.name()})"
|
|
401
|
+
)
|
|
402
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
403
|
+
pass
|
|
404
|
+
|
|
405
|
+
except (psutil.NoSuchProcess, psutil.AccessDenied):
|
|
406
|
+
pass
|
|
407
|
+
|
|
408
|
+
except ImportError:
|
|
409
|
+
# psutil not available, try basic approach
|
|
410
|
+
logger.warning("psutil not available, using basic process cleanup")
|
|
411
|
+
try:
|
|
412
|
+
import os
|
|
413
|
+
import signal
|
|
414
|
+
|
|
415
|
+
if self.process and hasattr(self.process, "pid"):
|
|
416
|
+
# Try to kill the process group
|
|
417
|
+
os.killpg(os.getpgid(self.process.pid), signal.SIGTERM)
|
|
418
|
+
time.sleep(2)
|
|
419
|
+
os.killpg(os.getpgid(self.process.pid), signal.SIGKILL)
|
|
420
|
+
except Exception as e:
|
|
421
|
+
logger.debug(f"Basic process cleanup failed: {e}")
|
|
422
|
+
except Exception as e:
|
|
423
|
+
logger.debug(f"Child process cleanup failed: {e}")
|
|
424
|
+
|
|
425
|
+
def _extract_error_details(self, output: str, command: str) -> str:
|
|
426
|
+
"""Extract detailed error information from command output, focusing on the tail"""
|
|
427
|
+
if not output:
|
|
428
|
+
return f"Command '{command}' failed with no output"
|
|
429
|
+
|
|
430
|
+
lines = output.split("\n")
|
|
431
|
+
non_empty_lines = [line.strip() for line in lines if line.strip()]
|
|
432
|
+
|
|
433
|
+
if not non_empty_lines:
|
|
434
|
+
return f"Command '{command}' failed with no readable output"
|
|
435
|
+
|
|
436
|
+
# Always return the last 10 lines of output for context
|
|
437
|
+
# This is most useful for build errors, compilation failures, etc.
|
|
438
|
+
tail_lines = non_empty_lines[-10:]
|
|
439
|
+
|
|
440
|
+
# Also look for critical error patterns in the tail
|
|
441
|
+
error_patterns = [
|
|
442
|
+
"error:",
|
|
443
|
+
"failed",
|
|
444
|
+
"cannot",
|
|
445
|
+
"permission denied",
|
|
446
|
+
"no such file or directory",
|
|
447
|
+
"command not found",
|
|
448
|
+
"syntax error",
|
|
449
|
+
"make: ***",
|
|
450
|
+
"fatal:",
|
|
451
|
+
"abort:",
|
|
452
|
+
"exception:",
|
|
453
|
+
"compilation terminated",
|
|
454
|
+
"build failed",
|
|
455
|
+
]
|
|
456
|
+
|
|
457
|
+
# Find error lines in the tail
|
|
458
|
+
error_lines = []
|
|
459
|
+
for line in tail_lines:
|
|
460
|
+
line_lower = line.lower()
|
|
461
|
+
if any(pattern in line_lower for pattern in error_patterns):
|
|
462
|
+
error_lines.append(line)
|
|
463
|
+
|
|
464
|
+
# If we found specific errors in tail, prioritize those
|
|
465
|
+
if error_lines:
|
|
466
|
+
return f"Errors found: {' | '.join(error_lines[:5])}"
|
|
467
|
+
|
|
468
|
+
# Otherwise return the complete tail for context
|
|
469
|
+
return f"Command failed - Last 10 lines: {' | '.join(tail_lines)}"
|
|
470
|
+
|
|
471
|
+
def _cleanup_on_error(self):
|
|
472
|
+
"""Clean up processes when command fails or times out"""
|
|
473
|
+
logger.info("Cleaning up processes due to error/timeout")
|
|
474
|
+
try:
|
|
475
|
+
# Kill child processes but keep the shell session alive if possible
|
|
476
|
+
self._kill_child_processes()
|
|
477
|
+
except Exception as e:
|
|
478
|
+
logger.debug(f"Error during cleanup: {e}")
|
|
479
|
+
|
|
480
|
+
def get_status(self) -> Dict[str, Any]:
|
|
481
|
+
"""Get current status of the shell session"""
|
|
482
|
+
return {
|
|
483
|
+
"is_active": self.is_active,
|
|
484
|
+
"shell_cmd": self.shell_cmd,
|
|
485
|
+
"cwd": self.cwd,
|
|
486
|
+
"created_at": self.created_at,
|
|
487
|
+
"commands_executed": len(self.command_history),
|
|
488
|
+
"process_alive": self.process.isalive() if self.process else False,
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
def _set_custom_prompt(self):
|
|
492
|
+
"""Set a custom prompt to avoid ANSI escape sequences"""
|
|
493
|
+
try:
|
|
494
|
+
if self.custom_prompt:
|
|
495
|
+
self.process.sendline(f'export PS1="{self.custom_prompt}"')
|
|
496
|
+
# Update prompt pattern to match custom prompt
|
|
497
|
+
self.prompt_pattern = [re.escape(self.custom_prompt)]
|
|
498
|
+
# Wait for new prompt
|
|
499
|
+
self.process.expect(self.prompt_pattern, timeout=10)
|
|
500
|
+
# Clear any remaining output
|
|
501
|
+
self.process.sendline("")
|
|
502
|
+
self.process.expect(self.prompt_pattern, timeout=5)
|
|
503
|
+
logger.info(f"Set custom prompt: {self.custom_prompt}")
|
|
504
|
+
except Exception as e:
|
|
505
|
+
logger.warning(f"Failed to set custom prompt: {e}")
|
|
506
|
+
|
|
507
|
+
def _clear_buffer(self):
|
|
508
|
+
"""Clear any accumulated output in the process buffer"""
|
|
509
|
+
if not self.process or not self.is_active:
|
|
510
|
+
return
|
|
511
|
+
|
|
512
|
+
try:
|
|
513
|
+
while True:
|
|
514
|
+
try:
|
|
515
|
+
discarded = self.process.read_nonblocking(size=1000, timeout=0.1)
|
|
516
|
+
if not discarded:
|
|
517
|
+
break
|
|
518
|
+
logger.debug(
|
|
519
|
+
f"Discarded buffer content: {repr(discarded[:100])}..."
|
|
520
|
+
)
|
|
521
|
+
except pexpect.TIMEOUT:
|
|
522
|
+
break
|
|
523
|
+
except pexpect.EOF:
|
|
524
|
+
break
|
|
525
|
+
except Exception as e:
|
|
526
|
+
logger.debug(f"Error clearing buffer: {e}")
|
|
527
|
+
|
|
528
|
+
def _clean_output(self, raw_output: str, command: str) -> str:
|
|
529
|
+
"""Clean up raw pexpect output"""
|
|
530
|
+
if not raw_output:
|
|
531
|
+
return ""
|
|
532
|
+
|
|
533
|
+
# Remove ANSI escape sequences
|
|
534
|
+
cleaned = re.sub(r"\x1b\[[?]?[0-9;]*[a-zA-Z]", "", raw_output)
|
|
535
|
+
|
|
536
|
+
# Remove carriage returns
|
|
537
|
+
cleaned = cleaned.replace("\r", "")
|
|
538
|
+
|
|
539
|
+
# For commands that produce important status output, preserve more content
|
|
540
|
+
# This can be configured by caller through command patterns or other means
|
|
541
|
+
|
|
542
|
+
# For other commands, use the original cleaning logic
|
|
543
|
+
lines = cleaned.split("\n")
|
|
544
|
+
clean_lines = []
|
|
545
|
+
|
|
546
|
+
skip_command_echo = False
|
|
547
|
+
|
|
548
|
+
for line in lines:
|
|
549
|
+
# Skip the echoed command line
|
|
550
|
+
if command in line and not skip_command_echo:
|
|
551
|
+
skip_command_echo = True
|
|
552
|
+
continue
|
|
553
|
+
elif skip_command_echo:
|
|
554
|
+
clean_lines.append(line)
|
|
555
|
+
|
|
556
|
+
# If we didn't find the command echo, return all lines
|
|
557
|
+
if not skip_command_echo:
|
|
558
|
+
clean_lines = lines
|
|
559
|
+
|
|
560
|
+
# Remove empty lines at start and end
|
|
561
|
+
while clean_lines and not clean_lines[0].strip():
|
|
562
|
+
clean_lines.pop(0)
|
|
563
|
+
while clean_lines and not clean_lines[-1].strip():
|
|
564
|
+
clean_lines.pop()
|
|
565
|
+
|
|
566
|
+
return "\n".join(clean_lines)
|
|
567
|
+
|
|
568
|
+
def _is_real_prompt_match(self, output: str, prompt_pattern: str) -> bool:
|
|
569
|
+
"""Check if a prompt match is real or just appears in log output"""
|
|
570
|
+
if not output:
|
|
571
|
+
return False
|
|
572
|
+
|
|
573
|
+
# Get the last few lines of output
|
|
574
|
+
lines = output.split("\n")
|
|
575
|
+
last_lines = lines[-3:] # Check last 3 lines
|
|
576
|
+
|
|
577
|
+
# Look for the prompt pattern at the start or end of recent lines
|
|
578
|
+
import re
|
|
579
|
+
|
|
580
|
+
for line in last_lines:
|
|
581
|
+
line = line.strip()
|
|
582
|
+
# Check if prompt appears at the end of line (most common)
|
|
583
|
+
if line.endswith(prompt_pattern.replace("\\", "")):
|
|
584
|
+
return True
|
|
585
|
+
# Check if prompt is the only thing on the line
|
|
586
|
+
if line == prompt_pattern.replace("\\", "") or re.match(
|
|
587
|
+
prompt_pattern, line
|
|
588
|
+
):
|
|
589
|
+
return True
|
|
590
|
+
|
|
591
|
+
return False
|
|
592
|
+
|
|
593
|
+
def _check_command_success(
|
|
594
|
+
self, output: str, command: str, continue_on_error: bool = False
|
|
595
|
+
) -> bool:
|
|
596
|
+
"""Check if command was successful based on output"""
|
|
597
|
+
if continue_on_error:
|
|
598
|
+
return True
|
|
599
|
+
|
|
600
|
+
if not output:
|
|
601
|
+
return True # No output might be success for some commands
|
|
602
|
+
|
|
603
|
+
output_lower = output.lower()
|
|
604
|
+
|
|
605
|
+
# Common error indicators
|
|
606
|
+
error_indicators = [
|
|
607
|
+
"error:",
|
|
608
|
+
"failed",
|
|
609
|
+
"cannot",
|
|
610
|
+
"permission denied",
|
|
611
|
+
"no such file or directory",
|
|
612
|
+
"command not found",
|
|
613
|
+
"syntax error",
|
|
614
|
+
"make: *** [",
|
|
615
|
+
"compilation terminated",
|
|
616
|
+
"build failed",
|
|
617
|
+
"fatal error",
|
|
618
|
+
"abort",
|
|
619
|
+
"segmentation fault",
|
|
620
|
+
]
|
|
621
|
+
|
|
622
|
+
for indicator in error_indicators:
|
|
623
|
+
if indicator in output_lower:
|
|
624
|
+
return False
|
|
625
|
+
|
|
626
|
+
return True
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
class ShellSessionManager:
|
|
630
|
+
"""Manages multiple interactive shell sessions"""
|
|
631
|
+
|
|
632
|
+
def __init__(self):
|
|
633
|
+
self.sessions: Dict[str, InteractiveShell] = {}
|
|
634
|
+
|
|
635
|
+
def create_session(
|
|
636
|
+
self, session_id: str, shell_cmd: str = "bash", **kwargs
|
|
637
|
+
) -> Dict[str, Any]:
|
|
638
|
+
"""Create a new shell session"""
|
|
639
|
+
if session_id in self.sessions:
|
|
640
|
+
return {"success": False, "error": f"Session '{session_id}' already exists"}
|
|
641
|
+
|
|
642
|
+
try:
|
|
643
|
+
session = InteractiveShell(shell_cmd=shell_cmd, **kwargs)
|
|
644
|
+
result = session.start()
|
|
645
|
+
|
|
646
|
+
if result["success"]:
|
|
647
|
+
self.sessions[session_id] = session
|
|
648
|
+
result["session_id"] = session_id
|
|
649
|
+
else:
|
|
650
|
+
result["error"] = (
|
|
651
|
+
f"Failed to create session '{session_id}': {result.get('error', 'Unknown error')}"
|
|
652
|
+
)
|
|
653
|
+
|
|
654
|
+
return result
|
|
655
|
+
except Exception as e:
|
|
656
|
+
return {
|
|
657
|
+
"success": False,
|
|
658
|
+
"error": f"Exception creating session '{session_id}': {str(e)}",
|
|
659
|
+
"shell_cmd": shell_cmd,
|
|
660
|
+
"kwargs": kwargs,
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
def get_session(self, session_id: str) -> Optional[InteractiveShell]:
|
|
664
|
+
"""Get an existing shell session"""
|
|
665
|
+
return self.sessions.get(session_id)
|
|
666
|
+
|
|
667
|
+
def execute_in_session(
|
|
668
|
+
self, session_id: str, command: str, **kwargs
|
|
669
|
+
) -> Dict[str, Any]:
|
|
670
|
+
"""Execute command in a specific session"""
|
|
671
|
+
session = self.get_session(session_id)
|
|
672
|
+
if not session:
|
|
673
|
+
return {
|
|
674
|
+
"success": False,
|
|
675
|
+
"error": f"Session '{session_id}' not found in active sessions: {list(self.sessions.keys())}",
|
|
676
|
+
}
|
|
677
|
+
|
|
678
|
+
try:
|
|
679
|
+
result = session.execute_command(command, **kwargs)
|
|
680
|
+
result["session_id"] = session_id
|
|
681
|
+
return result
|
|
682
|
+
except Exception as e:
|
|
683
|
+
return {
|
|
684
|
+
"success": False,
|
|
685
|
+
"error": f"Exception executing command in session '{session_id}': {str(e)}",
|
|
686
|
+
"command": command,
|
|
687
|
+
"session_id": session_id,
|
|
688
|
+
}
|
|
689
|
+
|
|
690
|
+
def close_session(self, session_id: str) -> Dict[str, Any]:
|
|
691
|
+
"""Close and remove a session"""
|
|
692
|
+
session = self.sessions.pop(session_id, None)
|
|
693
|
+
if not session:
|
|
694
|
+
return {"success": False, "error": f"Session '{session_id}' not found"}
|
|
695
|
+
|
|
696
|
+
result = session.close()
|
|
697
|
+
result["session_id"] = session_id
|
|
698
|
+
return result
|
|
699
|
+
|
|
700
|
+
def list_sessions(self) -> Dict[str, Any]:
|
|
701
|
+
"""List all active sessions"""
|
|
702
|
+
sessions_info = {}
|
|
703
|
+
for session_id, session in self.sessions.items():
|
|
704
|
+
sessions_info[session_id] = session.get_status()
|
|
705
|
+
|
|
706
|
+
return {"success": True, "sessions": sessions_info}
|
|
707
|
+
|
|
708
|
+
def cleanup_inactive_sessions(self) -> Dict[str, Any]:
|
|
709
|
+
"""Remove inactive sessions"""
|
|
710
|
+
inactive_sessions = []
|
|
711
|
+
for session_id, session in list(self.sessions.items()):
|
|
712
|
+
if not session.is_active or (
|
|
713
|
+
session.process and not session.process.isalive()
|
|
714
|
+
):
|
|
715
|
+
inactive_sessions.append(session_id)
|
|
716
|
+
session.close()
|
|
717
|
+
del self.sessions[session_id]
|
|
718
|
+
|
|
719
|
+
return {
|
|
720
|
+
"success": True,
|
|
721
|
+
"cleaned_sessions": inactive_sessions,
|
|
722
|
+
"message": f"Cleaned up {len(inactive_sessions)} inactive sessions",
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
def cleanup_all_sessions(self) -> Dict[str, Any]:
|
|
726
|
+
"""Force close all sessions and clean up their processes"""
|
|
727
|
+
closed_sessions = []
|
|
728
|
+
for session_id, session in list(self.sessions.items()):
|
|
729
|
+
try:
|
|
730
|
+
session.close()
|
|
731
|
+
closed_sessions.append(session_id)
|
|
732
|
+
except Exception as e:
|
|
733
|
+
logger.error(f"Error closing session {session_id}: {e}")
|
|
734
|
+
|
|
735
|
+
self.sessions.clear()
|
|
736
|
+
|
|
737
|
+
return {
|
|
738
|
+
"success": True,
|
|
739
|
+
"closed_sessions": closed_sessions,
|
|
740
|
+
"message": f"Force closed {len(closed_sessions)} sessions",
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
|
|
744
|
+
# Global session manager
|
|
745
|
+
session_manager = ShellSessionManager()
|