winterm-mcp 0.1.5__py3-none-any.whl → 0.1.7__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.
winterm_mcp/service.py CHANGED
@@ -7,51 +7,67 @@ import threading
7
7
  import uuid
8
8
  import time
9
9
  import os
10
- import shutil
11
10
  import logging
12
11
  from datetime import datetime
13
- from typing import Dict, Optional, Any, List
14
-
15
- # 版本号
16
- __version__ = "0.1.5"
17
-
18
- # 配置日志
19
- logger = logging.getLogger("winterm-mcp")
12
+ from typing import Dict, Optional, Any, List, Literal
13
+
14
+ try:
15
+ import winpty
16
+ WINPTY_AVAILABLE = True
17
+ except ImportError:
18
+ WINPTY_AVAILABLE = False
19
+
20
+ from .models import CommandInfo, QueryStatusResponse, RunCommandParams
21
+ from .store import CommandStore
22
+ from .utils import find_powershell, find_cmd, resolve_executable_path, strip_ansi_codes
23
+ from .constants import (
24
+ NAME,
25
+ VERSION,
26
+ ENV_POWERSHELL_PATH,
27
+ ENV_CMD_PATH,
28
+ ENV_PYTHON_PATH,
29
+ PTY_COLS,
30
+ PTY_ROWS,
31
+ MIN_TIMEOUT,
32
+ MAX_TIMEOUT,
33
+ DEFAULT_TIMEOUT,
34
+ )
35
+
36
+ __version__ = VERSION
37
+
38
+ logger = logging.getLogger(NAME)
20
39
 
21
40
 
22
41
  def setup_logging(level: int = logging.INFO) -> None:
23
42
  """
24
43
  配置日志输出
25
-
44
+
26
45
  Args:
27
46
  level: 日志级别,默认 INFO
28
-
47
+
29
48
  日志输出位置:
30
49
  1. 控制台 (stderr)
31
50
  2. 文件: %TEMP%/winterm-mcp.log 或 /tmp/winterm-mcp.log
32
-
51
+
33
52
  可通过环境变量配置:
34
53
  - WINTERM_LOG_LEVEL: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
35
54
  - WINTERM_LOG_FILE: 自定义日志文件路径
36
55
  """
37
56
  import tempfile
38
-
57
+
39
58
  formatter = logging.Formatter(
40
59
  "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
41
60
  datefmt="%Y-%m-%d %H:%M:%S"
42
61
  )
43
-
44
- # 控制台输出
62
+
45
63
  console_handler = logging.StreamHandler()
46
64
  console_handler.setFormatter(formatter)
47
65
  logger.addHandler(console_handler)
48
-
49
- # 文件输出
66
+
50
67
  log_file = os.environ.get("WINTERM_LOG_FILE")
51
68
  if not log_file:
52
- # 默认日志文件路径
53
69
  log_file = os.path.join(tempfile.gettempdir(), "winterm-mcp.log")
54
-
70
+
55
71
  try:
56
72
  file_handler = logging.FileHandler(log_file, encoding="utf-8")
57
73
  file_handler.setFormatter(formatter)
@@ -59,126 +75,38 @@ def setup_logging(level: int = logging.INFO) -> None:
59
75
  logger.info(f"Log file: {log_file}")
60
76
  except Exception as e:
61
77
  logger.warning(f"Failed to create log file {log_file}: {e}")
62
-
78
+
63
79
  logger.setLevel(level)
64
-
65
- # 检查环境变量设置日志级别
80
+
66
81
  env_level = os.environ.get("WINTERM_LOG_LEVEL", "").upper()
67
82
  if env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
68
83
  logger.setLevel(getattr(logging, env_level))
69
84
 
70
85
 
71
- # PowerShell 可执行文件的标准路径(按优先级排序)
72
- POWERSHELL_PATHS: List[str] = [
73
- r"C:\Windows\System32\WindowsPowerShell\v1.0\powershell.exe",
74
- r"C:\Windows\SysWOW64\WindowsPowerShell\v1.0\powershell.exe",
75
- ]
76
-
77
- # PowerShell Core (pwsh) 的常见路径
78
- PWSH_PATHS: List[str] = [
79
- r"C:\Program Files\PowerShell\7\pwsh.exe",
80
- r"C:\Program Files (x86)\PowerShell\7\pwsh.exe",
81
- ]
82
-
83
- # 环境变量名称
84
- ENV_POWERSHELL_PATH = "WINTERM_POWERSHELL_PATH"
85
-
86
-
87
- def _find_powershell() -> str:
88
- """
89
- 查找可用的 PowerShell 可执行文件路径
90
-
91
- 查找顺序:
92
- 1. 环境变量 WINTERM_POWERSHELL_PATH(用户自定义)
93
- 2. Windows PowerShell 标准路径
94
- 3. PowerShell Core 标准路径
95
- 4. PATH 环境变量中的 powershell/pwsh(兼容正常环境)
96
-
97
- Returns:
98
- PowerShell 可执行文件的绝对路径
99
-
100
- Raises:
101
- FileNotFoundError: 如果找不到 PowerShell
102
- """
103
- logger.debug("Starting PowerShell path discovery...")
104
-
105
- # 1. 检查用户配置的环境变量
106
- custom_path = os.environ.get(ENV_POWERSHELL_PATH)
107
- if custom_path:
108
- logger.debug(f"Found env var {ENV_POWERSHELL_PATH}={custom_path}")
109
- if os.path.isfile(custom_path):
110
- logger.info(f"Using custom PowerShell path: {custom_path}")
111
- return custom_path
112
- else:
113
- logger.warning(
114
- f"Custom PowerShell path not found: {custom_path}, "
115
- "falling back to standard paths"
116
- )
117
-
118
- # 2. 检查 Windows PowerShell 标准路径
119
- for path in POWERSHELL_PATHS:
120
- logger.debug(f"Checking standard path: {path}")
121
- if os.path.isfile(path):
122
- logger.info(f"Found Windows PowerShell: {path}")
123
- return path
124
-
125
- # 3. 检查 PowerShell Core 标准路径
126
- for path in PWSH_PATHS:
127
- logger.debug(f"Checking PowerShell Core path: {path}")
128
- if os.path.isfile(path):
129
- logger.info(f"Found PowerShell Core: {path}")
130
- return path
131
-
132
- # 4. 尝试 PATH 环境变量(兼容正常环境)
133
- logger.debug("Checking PATH environment variable...")
134
- ps_path = shutil.which("powershell")
135
- if ps_path:
136
- logger.info(f"Found PowerShell in PATH: {ps_path}")
137
- return ps_path
138
-
139
- pwsh_path = shutil.which("pwsh")
140
- if pwsh_path:
141
- logger.info(f"Found pwsh in PATH: {pwsh_path}")
142
- return pwsh_path
143
-
144
- # 所有方法都失败
145
- checked_paths = POWERSHELL_PATHS + PWSH_PATHS
146
- error_msg = (
147
- f"PowerShell not found. "
148
- f"Set {ENV_POWERSHELL_PATH} environment variable or "
149
- f"ensure PowerShell is installed. "
150
- f"Checked paths: {', '.join(checked_paths)}"
151
- )
152
- logger.error(error_msg)
153
- raise FileNotFoundError(error_msg)
154
-
155
-
156
86
  def get_version() -> str:
157
87
  """
158
88
  获取 winterm-mcp 版本号
159
-
89
+
160
90
  Returns:
161
91
  版本号字符串
162
92
  """
163
93
  return __version__
164
94
 
165
95
 
166
- class RunCmdService:
96
+ class CommandService:
167
97
  """
168
98
  异步命令执行服务类,管理所有异步命令的执行和状态
169
99
  """
170
100
 
171
101
  def __init__(self):
172
- self.commands: Dict[str, Dict[str, Any]] = {}
173
- self.lock = threading.Lock()
102
+ self._store = CommandStore()
174
103
  self._powershell_path: Optional[str] = None
104
+ self._cmd_path: Optional[str] = None
175
105
 
176
106
  def _get_powershell_path(self) -> str:
177
107
  """
178
108
  获取 PowerShell 可执行文件路径(带缓存)
179
109
 
180
- 首次调用时查找并缓存路径,后续调用直接返回缓存值。
181
-
182
110
  Returns:
183
111
  PowerShell 可执行文件的绝对路径
184
112
 
@@ -186,58 +114,97 @@ class RunCmdService:
186
114
  FileNotFoundError: 如果找不到 PowerShell
187
115
  """
188
116
  if self._powershell_path is None:
189
- self._powershell_path = _find_powershell()
117
+ self._powershell_path = find_powershell()
190
118
  logger.debug(f"PowerShell path cached: {self._powershell_path}")
191
119
  return self._powershell_path
192
120
 
121
+ def _get_cmd_path(self) -> str:
122
+ """
123
+ 获取 CMD 可执行文件路径(带缓存)
124
+
125
+ Returns:
126
+ CMD 可执行文件的绝对路径
127
+
128
+ Raises:
129
+ FileNotFoundError: 如果找不到 CMD
130
+ """
131
+ if self._cmd_path is None:
132
+ self._cmd_path = find_cmd()
133
+ logger.debug(f"CMD path cached: {self._cmd_path}")
134
+ return self._cmd_path
135
+
193
136
  def run_command(
194
137
  self,
195
138
  command: str,
196
- shell_type: str = "powershell",
197
- timeout: int = 30,
139
+ executable: Optional[str] = None,
140
+ args: Optional[List[str]] = None,
141
+ shell_type: Literal["powershell", "cmd", "executable"] = "executable",
142
+ timeout: int = DEFAULT_TIMEOUT,
198
143
  working_directory: Optional[str] = None,
144
+ enable_streaming: bool = False,
199
145
  ) -> str:
200
146
  """
201
147
  异步运行命令
202
148
 
203
149
  Args:
204
150
  command: 要执行的命令
205
- shell_type: Shell 类型 (powershell 或 cmd)
151
+ executable: 可执行文件路径
152
+ args: 可执行文件参数
153
+ shell_type: Shell 类型 (powershell/cmd/executable)
206
154
  timeout: 超时时间(秒)
207
155
  working_directory: 工作目录
156
+ enable_streaming: 启用实时流式输出
208
157
 
209
158
  Returns:
210
159
  命令执行的token
211
160
  """
161
+ if not command:
162
+ raise ValueError("command cannot be empty")
163
+
164
+ if len(command) > 1000:
165
+ raise ValueError("command length cannot exceed 1000 characters")
166
+
167
+ if timeout < MIN_TIMEOUT or timeout > MAX_TIMEOUT:
168
+ raise ValueError(f"timeout must be between {MIN_TIMEOUT} and {MAX_TIMEOUT}")
169
+
170
+ if shell_type not in ["powershell", "cmd", "executable"]:
171
+ raise ValueError("shell_type must be 'powershell', 'cmd', or 'executable'")
172
+
212
173
  token = str(uuid.uuid4())
213
-
174
+
214
175
  logger.info(
215
176
  f"Submitting command: token={token}, shell={shell_type}, "
216
- f"timeout={timeout}, cwd={working_directory}"
177
+ f"timeout={timeout}, cwd={working_directory}, streaming={enable_streaming}"
178
+ )
179
+ logger.debug(
180
+ f"Command content: {command[:100]}"
181
+ f"{'...' if len(command) > 100 else ''}"
182
+ )
183
+
184
+ cmd_info = CommandInfo(
185
+ token=token,
186
+ executable=executable or "",
187
+ args=args or [],
188
+ command=command,
189
+ shell_type=shell_type,
190
+ status="pending",
191
+ start_time=datetime.now(),
192
+ timeout=timeout,
193
+ working_directory=working_directory,
194
+ stdout="",
195
+ stderr="",
196
+ exit_code=None,
197
+ execution_time=None,
198
+ timeout_occurred=False,
199
+ pty_process=None,
200
+ enable_streaming=enable_streaming,
217
201
  )
218
- logger.debug(f"Command content: {command[:100]}{'...' if len(command) > 100 else ''}")
219
-
220
- cmd_info = {
221
- "token": token,
222
- "command": command,
223
- "shell_type": shell_type,
224
- "status": "pending",
225
- "start_time": datetime.now(),
226
- "timeout": timeout,
227
- "working_directory": working_directory,
228
- "stdout": "",
229
- "stderr": "",
230
- "exit_code": None,
231
- "execution_time": None,
232
- "timeout_occurred": False,
233
- }
234
202
 
235
- with self.lock:
236
- self.commands[token] = cmd_info
203
+ self._store.add_command(token, cmd_info)
237
204
 
238
205
  thread = threading.Thread(
239
206
  target=self._execute_command,
240
- args=(token, command, shell_type, timeout, working_directory),
207
+ args=(token, command, executable, args, shell_type, timeout, working_directory, enable_streaming),
241
208
  )
242
209
  thread.daemon = True
243
210
  thread.start()
@@ -248,9 +215,12 @@ class RunCmdService:
248
215
  self,
249
216
  token: str,
250
217
  command: str,
218
+ executable: Optional[str],
219
+ args: Optional[List[str]],
251
220
  shell_type: str,
252
221
  timeout: int,
253
222
  working_directory: Optional[str],
223
+ enable_streaming: bool,
254
224
  ):
255
225
  """
256
226
  在单独线程中执行命令
@@ -259,14 +229,11 @@ class RunCmdService:
259
229
  start_time = time.time()
260
230
  logger.debug(f"[{token}] Starting command execution...")
261
231
 
262
- with self.lock:
263
- if token in self.commands:
264
- self.commands[token]["status"] = "running"
232
+ self._store.update_command(token, status="running")
265
233
 
266
234
  encoding = "gbk"
267
235
 
268
236
  if shell_type == "powershell":
269
- # 使用绝对路径调用 PowerShell,避免 PATH 环境变量限制
270
237
  ps_path = self._get_powershell_path()
271
238
  logger.info(f"[{token}] Using PowerShell: {ps_path}")
272
239
  cmd_args = [
@@ -279,90 +246,225 @@ class RunCmdService:
279
246
  "-Command",
280
247
  command,
281
248
  ]
249
+ elif shell_type == "cmd":
250
+ cmd_path = self._get_cmd_path()
251
+ logger.info(f"[{token}] Using CMD: {cmd_path}")
252
+ cmd_args = [cmd_path, "/c", command]
282
253
  else:
283
- logger.debug(f"[{token}] Using cmd.exe")
284
- cmd_args = ["cmd", "/c", command]
254
+ if executable:
255
+ resolved_exec = resolve_executable_path(executable)
256
+ logger.info(f"[{token}] Using executable: {resolved_exec}")
257
+ cmd_args = [resolved_exec] + (args or [])
258
+ else:
259
+ logger.debug(f"[{token}] Using shell=True for command")
260
+ cmd_args = command
285
261
 
286
262
  logger.debug(f"[{token}] Executing: {cmd_args}")
287
-
288
- result = subprocess.run(
263
+
264
+ env = None
265
+ python_path = os.environ.get(ENV_PYTHON_PATH)
266
+ if python_path and os.path.isfile(python_path):
267
+ env = os.environ.copy()
268
+ python_dir = os.path.dirname(python_path)
269
+ env["PATH"] = f"{python_dir}{os.pathsep}{env.get('PATH', '')}"
270
+ logger.debug(f"[{token}] Using custom Python path: {python_path}")
271
+
272
+ if enable_streaming and WINPTY_AVAILABLE:
273
+ self._execute_with_pty(
274
+ token, cmd_args, shell_type, timeout, working_directory, env, start_time
275
+ )
276
+ else:
277
+ self._execute_with_subprocess(
278
+ token, cmd_args, shell_type, timeout, working_directory, env, start_time, encoding
279
+ )
280
+
281
+ except FileNotFoundError as e:
282
+ execution_time = time.time() - start_time
283
+ logger.error(f"[{token}] Executable not found: {e}")
284
+ self._store.update_command(
285
+ token,
286
+ status="not_found",
287
+ stdout="",
288
+ stderr=f"Executable not found: {e}",
289
+ exit_code=-2,
290
+ execution_time=int(execution_time * 1000),
291
+ )
292
+ except Exception as e:
293
+ execution_time = time.time() - start_time
294
+ logger.error(f"[{token}] Command failed with exception: {e}")
295
+ self._store.update_command(
296
+ token,
297
+ status="completed",
298
+ stdout="",
299
+ stderr=str(e),
300
+ exit_code=-1,
301
+ execution_time=int(execution_time * 1000),
302
+ )
303
+
304
+ def _execute_with_subprocess(
305
+ self,
306
+ token: str,
307
+ cmd_args: List[str] | str,
308
+ shell_type: str,
309
+ timeout: int,
310
+ working_directory: Optional[str],
311
+ env: Optional[Dict[str, str]],
312
+ start_time: float,
313
+ encoding: str,
314
+ ):
315
+ """
316
+ 使用 subprocess 执行命令
317
+ """
318
+ result = subprocess.run(
319
+ cmd_args,
320
+ capture_output=True,
321
+ text=True,
322
+ timeout=timeout,
323
+ cwd=working_directory,
324
+ encoding=encoding,
325
+ stdin=subprocess.DEVNULL,
326
+ env=env,
327
+ shell=(shell_type == "executable" and isinstance(cmd_args, str)),
328
+ )
329
+
330
+ execution_time = time.time() - start_time
331
+
332
+ stdout_clean = strip_ansi_codes(result.stdout) if result.stdout else ""
333
+ stderr_clean = strip_ansi_codes(result.stderr) if result.stderr else ""
334
+
335
+ logger.info(
336
+ f"[{token}] Command completed: exit_code={result.returncode}, "
337
+ f"time={execution_time:.3f}s"
338
+ )
339
+ logger.debug(
340
+ f"[{token}] stdout: "
341
+ f"{stdout_clean[:200] if stdout_clean else '(empty)'}"
342
+ )
343
+ logger.debug(
344
+ f"[{token}] stderr: "
345
+ f"{stderr_clean[:200] if stderr_clean else '(empty)'}"
346
+ )
347
+
348
+ self._store.update_command(
349
+ token,
350
+ status="completed",
351
+ stdout=stdout_clean,
352
+ stderr=stderr_clean,
353
+ exit_code=result.returncode,
354
+ execution_time=int(execution_time * 1000),
355
+ )
356
+
357
+ def _execute_with_pty(
358
+ self,
359
+ token: str,
360
+ cmd_args: List[str] | str,
361
+ shell_type: str,
362
+ timeout: int,
363
+ working_directory: Optional[str],
364
+ env: Optional[Dict[str, str]],
365
+ start_time: float,
366
+ ):
367
+ """
368
+ 使用 winpty 执行命令,支持交互式输入和实时流式输出
369
+ """
370
+ if isinstance(cmd_args, str):
371
+ logger.warning(f"[{token}] PTY mode requires list of arguments, falling back to subprocess")
372
+ self._execute_with_subprocess(
373
+ token, cmd_args, shell_type, timeout, working_directory, env, start_time, "gbk"
374
+ )
375
+ return
376
+
377
+ try:
378
+ cwd = working_directory or os.getcwd()
379
+ pty = winpty.PtyProcess.spawn(
289
380
  cmd_args,
290
- capture_output=True,
291
- text=True,
292
- timeout=timeout,
293
- cwd=working_directory,
294
- encoding=encoding,
295
- stdin=subprocess.DEVNULL, # 防止等待输入导致挂起
381
+ cols=PTY_COLS,
382
+ rows=PTY_ROWS,
383
+ cwd=cwd,
384
+ env=env or os.environ,
296
385
  )
297
386
 
387
+ self._store.update_command(token, pty_process=pty)
388
+ logger.info(f"[{token}] PTY process started: pid={pty.pid}")
389
+
390
+ stdout_buffer = ""
391
+ stderr_buffer = ""
392
+ timeout_timer = None
393
+ timeout_occurred = False
394
+
395
+ def on_timeout():
396
+ nonlocal timeout_occurred
397
+ timeout_occurred = True
398
+ logger.warning(f"[{token}] Command timed out after {timeout}s")
399
+ if pty and pty.isalive():
400
+ pty.terminate()
401
+
402
+ if timeout > 0:
403
+ timeout_timer = threading.Timer(timeout, on_timeout)
404
+ timeout_timer.start()
405
+
406
+ def on_data(data: str):
407
+ nonlocal stdout_buffer
408
+ stdout_buffer += data
409
+ self._store.update_command(
410
+ token,
411
+ stdout=stdout_buffer,
412
+ last_output_timestamp=int(time.time() * 1000),
413
+ )
414
+ logger.debug(f"[{token}] Received {len(data)} bytes of output")
415
+
416
+ pty.set_winpty_size(PTY_COLS, PTY_ROWS)
417
+
418
+ while pty.isalive() and not timeout_occurred:
419
+ try:
420
+ data = pty.read()
421
+ if data:
422
+ on_data(data)
423
+ time.sleep(0.01)
424
+ except Exception as e:
425
+ logger.error(f"[{token}] Error reading from PTY: {e}")
426
+ break
427
+
428
+ if timeout_timer:
429
+ timeout_timer.cancel()
430
+
431
+ exit_code = pty.get_exitstatus() if not pty.isalive() else -1
432
+
433
+ stdout_clean = strip_ansi_codes(stdout_buffer) if stdout_buffer else ""
298
434
  execution_time = time.time() - start_time
299
-
435
+
300
436
  logger.info(
301
- f"[{token}] Command completed: exit_code={result.returncode}, "
437
+ f"[{token}] PTY command completed: exit_code={exit_code}, "
302
438
  f"time={execution_time:.3f}s"
303
439
  )
304
- logger.debug(f"[{token}] stdout: {result.stdout[:200] if result.stdout else '(empty)'}")
305
- logger.debug(f"[{token}] stderr: {result.stderr[:200] if result.stderr else '(empty)'}")
306
-
307
- with self.lock:
308
- if token in self.commands:
309
- self.commands[token].update(
310
- {
311
- "status": "completed",
312
- "stdout": result.stdout,
313
- "stderr": result.stderr,
314
- "exit_code": result.returncode,
315
- "execution_time": execution_time,
316
- }
317
- )
440
+ logger.debug(
441
+ f"[{token}] stdout: "
442
+ f"{stdout_clean[:200] if stdout_clean else '(empty)'}"
443
+ )
444
+
445
+ self._store.update_command(
446
+ token,
447
+ status="terminated" if timeout_occurred else "completed",
448
+ stdout=stdout_clean,
449
+ stderr=stderr_buffer,
450
+ exit_code=exit_code,
451
+ execution_time=int(execution_time * 1000),
452
+ timeout_occurred=timeout_occurred,
453
+ pty_process=None,
454
+ )
318
455
 
319
- except FileNotFoundError as e:
320
- execution_time = time.time() - start_time
321
- logger.error(f"[{token}] PowerShell not found: {e}")
322
- with self.lock:
323
- if token in self.commands:
324
- self.commands[token].update(
325
- {
326
- "status": "completed",
327
- "stdout": "",
328
- "stderr": f"PowerShell not found: {e}",
329
- "exit_code": -2,
330
- "execution_time": execution_time,
331
- "timeout_occurred": False,
332
- }
333
- )
334
- except subprocess.TimeoutExpired:
335
- execution_time = time.time() - start_time
336
- logger.warning(f"[{token}] Command timed out after {timeout}s")
337
- with self.lock:
338
- if token in self.commands:
339
- self.commands[token].update(
340
- {
341
- "status": "completed",
342
- "stdout": "",
343
- "stderr": (
344
- f"Command timed out after {timeout} seconds"
345
- ),
346
- "exit_code": -1,
347
- "execution_time": execution_time,
348
- "timeout_occurred": True,
349
- }
350
- )
351
456
  except Exception as e:
352
457
  execution_time = time.time() - start_time
353
- logger.error(f"[{token}] Command failed with exception: {e}")
354
- with self.lock:
355
- if token in self.commands:
356
- self.commands[token].update(
357
- {
358
- "status": "completed",
359
- "stdout": "",
360
- "stderr": str(e),
361
- "exit_code": -1,
362
- "execution_time": execution_time,
363
- "timeout_occurred": False,
364
- }
365
- )
458
+ logger.error(f"[{token}] PTY execution failed: {e}")
459
+ self._store.update_command(
460
+ token,
461
+ status="completed",
462
+ stdout="",
463
+ stderr=str(e),
464
+ exit_code=-1,
465
+ execution_time=int(execution_time * 1000),
466
+ pty_process=None,
467
+ )
366
468
 
367
469
  def query_command_status(self, token: str) -> Dict[str, Any]:
368
470
  """
@@ -375,30 +477,233 @@ class RunCmdService:
375
477
  包含命令状态的字典
376
478
  """
377
479
  logger.debug(f"Querying status for token: {token}")
378
-
379
- with self.lock:
380
- if token not in self.commands:
381
- logger.warning(f"Token not found: {token}")
382
- return {
383
- "token": token,
384
- "status": "not_found",
385
- "message": "Token not found",
386
- }
387
-
388
- cmd_info = self.commands[token].copy()
389
- logger.debug(f"[{token}] Status: {cmd_info['status']}")
390
-
391
- if cmd_info["status"] == "running":
392
- return {"token": cmd_info["token"], "status": "running"}
393
- elif cmd_info["status"] in ["completed", "pending"]:
394
- return {
395
- "token": cmd_info["token"],
396
- "status": cmd_info["status"],
397
- "exit_code": cmd_info["exit_code"],
398
- "stdout": cmd_info["stdout"],
399
- "stderr": cmd_info["stderr"],
400
- "execution_time": cmd_info["execution_time"],
401
- "timeout_occurred": cmd_info["timeout_occurred"],
402
- }
403
- else:
404
- return cmd_info
480
+
481
+ cmd_info = self._store.get_command(token)
482
+
483
+ if not cmd_info:
484
+ logger.warning(f"Token not found: {token}")
485
+ return {
486
+ "token": token,
487
+ "status": "not_found",
488
+ "message": "Token not found",
489
+ }
490
+
491
+ logger.debug(f"[{token}] Status: {cmd_info.status}")
492
+
493
+ if cmd_info.status == "running":
494
+ return {"token": cmd_info.token, "status": "running"}
495
+ elif cmd_info.status in ["completed", "pending", "not_found", "terminated"]:
496
+ return {
497
+ "token": cmd_info.token,
498
+ "status": cmd_info.status,
499
+ "exit_code": cmd_info.exit_code,
500
+ "stdout": cmd_info.stdout,
501
+ "stderr": cmd_info.stderr,
502
+ "execution_time": cmd_info.execution_time,
503
+ "timeout_occurred": cmd_info.timeout_occurred,
504
+ }
505
+ else:
506
+ return {"token": cmd_info.token, "status": cmd_info.status}
507
+
508
+ def enhanced_query_command_status(
509
+ self, token: str, since_timestamp: Optional[int] = None
510
+ ) -> Dict[str, Any]:
511
+ """
512
+ 增强版状态查询,支持流式输出
513
+
514
+ Args:
515
+ token: 命令令牌
516
+ since_timestamp: 只返回此时间戳之后的输出(毫秒)
517
+
518
+ Returns:
519
+ 包含命令状态和结果的字典
520
+ """
521
+ logger.debug(f"Enhanced query for token: {token}, since: {since_timestamp}")
522
+
523
+ cmd_info = self._store.get_command(token)
524
+
525
+ if not cmd_info:
526
+ logger.warning(f"Token not found: {token}")
527
+ return {
528
+ "token": token,
529
+ "status": "not_found",
530
+ "message": "Token not found",
531
+ }
532
+
533
+ result = {
534
+ "token": cmd_info.token,
535
+ "status": cmd_info.status,
536
+ }
537
+
538
+ if cmd_info.status != "running":
539
+ result.update({
540
+ "exit_code": cmd_info.exit_code,
541
+ "stdout": cmd_info.stdout,
542
+ "stderr": cmd_info.stderr,
543
+ "execution_time": cmd_info.execution_time,
544
+ "timeout_occurred": cmd_info.timeout_occurred,
545
+ })
546
+
547
+ if since_timestamp is not None and cmd_info.last_output_timestamp > since_timestamp:
548
+ result["stdout"] = cmd_info.stdout
549
+ result["stderr"] = cmd_info.stderr
550
+
551
+ return result
552
+
553
+ def send_command_input(
554
+ self, token: str, input: str, append_newline: bool = True
555
+ ) -> Dict[str, Any]:
556
+ """
557
+ 向运行中的命令发送输入
558
+
559
+ Args:
560
+ token: 命令令牌
561
+ input: 要发送的输入
562
+ append_newline: 是否追加换行符
563
+
564
+ Returns:
565
+ 包含操作结果的字典
566
+ """
567
+ logger.debug(f"Sending input to token: {token}")
568
+
569
+ cmd_info = self._store.get_command(token)
570
+
571
+ if not cmd_info:
572
+ logger.warning(f"Token not found: {token}")
573
+ return {
574
+ "success": False,
575
+ "message": "Token not found",
576
+ "token": token,
577
+ }
578
+
579
+ if cmd_info.status != "running":
580
+ logger.warning(f"[{token}] Command is not running: {cmd_info.status}")
581
+ return {
582
+ "success": False,
583
+ "message": f"Command is not running: {cmd_info.status}",
584
+ "token": token,
585
+ }
586
+
587
+ if not cmd_info.pty_process:
588
+ logger.warning(f"[{token}] No PTY process available")
589
+ return {
590
+ "success": False,
591
+ "message": "PTY process not available",
592
+ "token": token,
593
+ }
594
+
595
+ try:
596
+ input_data = input
597
+ if append_newline:
598
+ input_data += "\r\n"
599
+
600
+ cmd_info.pty_process.write(input_data)
601
+ logger.debug(f"[{token}] Input sent successfully")
602
+ return {
603
+ "success": True,
604
+ "message": "Input sent successfully",
605
+ "token": token,
606
+ }
607
+ except Exception as e:
608
+ logger.error(f"[{token}] Failed to send input: {e}")
609
+ return {
610
+ "success": False,
611
+ "message": str(e),
612
+ "token": token,
613
+ }
614
+
615
+ def terminate_command(self, token: str) -> Dict[str, Any]:
616
+ """
617
+ 终止运行中的命令
618
+
619
+ Args:
620
+ token: 命令令牌
621
+
622
+ Returns:
623
+ 包含操作结果的字典
624
+ """
625
+ logger.debug(f"Terminating token: {token}")
626
+
627
+ cmd_info = self._store.get_command(token)
628
+
629
+ if not cmd_info:
630
+ logger.warning(f"Token not found: {token}")
631
+ return {
632
+ "success": False,
633
+ "message": "Token not found",
634
+ "token": token,
635
+ }
636
+
637
+ if cmd_info.status != "running":
638
+ logger.warning(f"[{token}] Command is not running: {cmd_info.status}")
639
+ return {
640
+ "success": False,
641
+ "message": f"Command is not running: {cmd_info.status}",
642
+ "token": token,
643
+ }
644
+
645
+ try:
646
+ if cmd_info.pty_process:
647
+ cmd_info.pty_process.terminate()
648
+ logger.debug(f"[{token}] PTY process terminated")
649
+
650
+ self._store.update_command(
651
+ token,
652
+ status="terminated",
653
+ exit_code=-1,
654
+ )
655
+
656
+ logger.info(f"[{token}] Command terminated successfully")
657
+ return {
658
+ "success": True,
659
+ "message": "Command terminated successfully",
660
+ "token": token,
661
+ }
662
+ except Exception as e:
663
+ logger.error(f"[{token}] Failed to terminate command: {e}")
664
+ return {
665
+ "success": False,
666
+ "message": str(e),
667
+ "token": token,
668
+ }
669
+
670
+ def get_version_info(self) -> Dict[str, Any]:
671
+ """
672
+ 获取版本信息
673
+
674
+ Returns:
675
+ 包含版本信息的字典
676
+ """
677
+ import sys
678
+ import platform
679
+
680
+ try:
681
+ ps_path = None
682
+ ps_error = None
683
+ try:
684
+ ps_path = find_powershell()
685
+ except FileNotFoundError as e:
686
+ ps_error = str(e)
687
+
688
+ return {
689
+ "version": get_version(),
690
+ "service_status": "running",
691
+ "python_version": sys.version,
692
+ "platform": sys.platform,
693
+ "arch": platform.machine(),
694
+ "env": {
695
+ "WINTERM_POWERSHELL_PATH": os.environ.get(ENV_POWERSHELL_PATH),
696
+ "WINTERM_CMD_PATH": os.environ.get(ENV_CMD_PATH),
697
+ "WINTERM_PYTHON_PATH": os.environ.get(ENV_PYTHON_PATH),
698
+ "WINTERM_LOG_LEVEL": os.environ.get("WINTERM_LOG_LEVEL"),
699
+ },
700
+ }
701
+ except Exception as e:
702
+ return {
703
+ "version": get_version(),
704
+ "service_status": "error",
705
+ "error": str(e),
706
+ }
707
+
708
+
709
+ RunCmdService = CommandService