winterm-mcp 0.1.6__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
@@ -1,427 +1,709 @@
1
- """
2
- winterm服务模块 - 异步执行Windows终端命令服务
3
- """
4
-
5
- import subprocess
6
- import threading
7
- import uuid
8
- import time
9
- import os
10
- import shutil
11
- import logging
12
- from datetime import datetime
13
- from typing import Dict, Optional, Any, List
14
-
15
- # 版本号
16
- __version__ = "0.1.6"
17
-
18
- # 配置日志
19
- logger = logging.getLogger("winterm-mcp")
20
-
21
-
22
- def setup_logging(level: int = logging.INFO) -> None:
23
- """
24
- 配置日志输出
25
-
26
- Args:
27
- level: 日志级别,默认 INFO
28
-
29
- 日志输出位置:
30
- 1. 控制台 (stderr)
31
- 2. 文件: %TEMP%/winterm-mcp.log 或 /tmp/winterm-mcp.log
32
-
33
- 可通过环境变量配置:
34
- - WINTERM_LOG_LEVEL: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
35
- - WINTERM_LOG_FILE: 自定义日志文件路径
36
- """
37
- import tempfile
38
-
39
- formatter = logging.Formatter(
40
- "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
41
- datefmt="%Y-%m-%d %H:%M:%S"
42
- )
43
-
44
- # 控制台输出
45
- console_handler = logging.StreamHandler()
46
- console_handler.setFormatter(formatter)
47
- logger.addHandler(console_handler)
48
-
49
- # 文件输出
50
- log_file = os.environ.get("WINTERM_LOG_FILE")
51
- if not log_file:
52
- # 默认日志文件路径
53
- log_file = os.path.join(tempfile.gettempdir(), "winterm-mcp.log")
54
-
55
- try:
56
- file_handler = logging.FileHandler(log_file, encoding="utf-8")
57
- file_handler.setFormatter(formatter)
58
- logger.addHandler(file_handler)
59
- logger.info(f"Log file: {log_file}")
60
- except Exception as e:
61
- logger.warning(f"Failed to create log file {log_file}: {e}")
62
-
63
- logger.setLevel(level)
64
-
65
- # 检查环境变量设置日志级别
66
- env_level = os.environ.get("WINTERM_LOG_LEVEL", "").upper()
67
- if env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
68
- logger.setLevel(getattr(logging, env_level))
69
-
70
-
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
- ENV_PYTHON_PATH = "WINTERM_PYTHON_PATH"
86
-
87
-
88
- def _find_powershell() -> str:
89
- """
90
- 查找可用的 PowerShell 可执行文件路径
91
-
92
- 查找顺序:
93
- 1. 环境变量 WINTERM_POWERSHELL_PATH(用户自定义)
94
- 2. Windows PowerShell 标准路径
95
- 3. PowerShell Core 标准路径
96
- 4. PATH 环境变量中的 powershell/pwsh(兼容正常环境)
97
-
98
- Returns:
99
- PowerShell 可执行文件的绝对路径
100
-
101
- Raises:
102
- FileNotFoundError: 如果找不到 PowerShell
103
- """
104
- logger.debug("Starting PowerShell path discovery...")
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
- def get_version() -> str:
157
- """
158
- 获取 winterm-mcp 版本号
159
-
160
- Returns:
161
- 版本号字符串
162
- """
163
- return __version__
164
-
165
-
166
- class RunCmdService:
167
- """
168
- 异步命令执行服务类,管理所有异步命令的执行和状态
169
- """
170
-
171
- def __init__(self):
172
- self.commands: Dict[str, Dict[str, Any]] = {}
173
- self.lock = threading.Lock()
174
- self._powershell_path: Optional[str] = None
175
-
176
- def _get_powershell_path(self) -> str:
177
- """
178
- 获取 PowerShell 可执行文件路径(带缓存)
179
-
180
- 首次调用时查找并缓存路径,后续调用直接返回缓存值。
181
-
182
- Returns:
183
- PowerShell 可执行文件的绝对路径
184
-
185
- Raises:
186
- FileNotFoundError: 如果找不到 PowerShell
187
- """
188
- if self._powershell_path is None:
189
- self._powershell_path = _find_powershell()
190
- logger.debug(f"PowerShell path cached: {self._powershell_path}")
191
- return self._powershell_path
192
-
193
- def run_command(
194
- self,
195
- command: str,
196
- shell_type: str = "powershell",
197
- timeout: int = 30,
198
- working_directory: Optional[str] = None,
199
- ) -> str:
200
- """
201
- 异步运行命令
202
-
203
- Args:
204
- command: 要执行的命令
205
- shell_type: Shell 类型 (powershell 或 cmd)
206
- timeout: 超时时间(秒)
207
- working_directory: 工作目录
208
-
209
- Returns:
210
- 命令执行的token
211
- """
212
- token = str(uuid.uuid4())
213
-
214
- logger.info(
215
- f"Submitting command: token={token}, shell={shell_type}, "
216
- f"timeout={timeout}, cwd={working_directory}"
217
- )
218
- logger.debug(
219
- f"Command content: {command[:100]}"
220
- f"{'...' if len(command) > 100 else ''}"
221
- )
222
-
223
- cmd_info = {
224
- "token": token,
225
- "command": command,
226
- "shell_type": shell_type,
227
- "status": "pending",
228
- "start_time": datetime.now(),
229
- "timeout": timeout,
230
- "working_directory": working_directory,
231
- "stdout": "",
232
- "stderr": "",
233
- "exit_code": None,
234
- "execution_time": None,
235
- "timeout_occurred": False,
236
- }
237
-
238
- with self.lock:
239
- self.commands[token] = cmd_info
240
-
241
- thread = threading.Thread(
242
- target=self._execute_command,
243
- args=(token, command, shell_type, timeout, working_directory),
244
- )
245
- thread.daemon = True
246
- thread.start()
247
-
248
- return token
249
-
250
- def _execute_command(
251
- self,
252
- token: str,
253
- command: str,
254
- shell_type: str,
255
- timeout: int,
256
- working_directory: Optional[str],
257
- ):
258
- """
259
- 在单独线程中执行命令
260
- """
261
- try:
262
- start_time = time.time()
263
- logger.debug(f"[{token}] Starting command execution...")
264
-
265
- with self.lock:
266
- if token in self.commands:
267
- self.commands[token]["status"] = "running"
268
-
269
- encoding = "gbk"
270
-
271
- if shell_type == "powershell":
272
- # 使用绝对路径调用 PowerShell,避免 PATH 环境变量限制
273
- ps_path = self._get_powershell_path()
274
- logger.info(f"[{token}] Using PowerShell: {ps_path}")
275
- cmd_args = [
276
- ps_path,
277
- "-NoProfile",
278
- "-NoLogo",
279
- "-NonInteractive",
280
- "-ExecutionPolicy",
281
- "Bypass",
282
- "-Command",
283
- command,
284
- ]
285
- else:
286
- logger.debug(f"[{token}] Using cmd.exe")
287
- cmd_args = ["cmd", "/c", command]
288
-
289
- logger.debug(f"[{token}] Executing: {cmd_args}")
290
-
291
- # 处理 Python 路径环境变量
292
- env = None
293
- python_path = os.environ.get(ENV_PYTHON_PATH)
294
- if python_path and os.path.isfile(python_path):
295
- env = os.environ.copy()
296
- python_dir = os.path.dirname(python_path)
297
- env["PATH"] = (
298
- f"{python_dir}{os.pathsep}{env.get('PATH', '')}"
299
- )
300
- logger.debug(
301
- f"[{token}] Using custom Python path: {python_path}"
302
- )
303
-
304
- result = subprocess.run(
305
- cmd_args,
306
- capture_output=True,
307
- text=True,
308
- timeout=timeout,
309
- cwd=working_directory,
310
- encoding=encoding,
311
- stdin=subprocess.DEVNULL, # 防止等待输入导致挂起
312
- env=env,
313
- )
314
-
315
- execution_time = time.time() - start_time
316
-
317
- logger.info(
318
- f"[{token}] Command completed: exit_code={result.returncode}, "
319
- f"time={execution_time:.3f}s"
320
- )
321
- logger.debug(
322
- f"[{token}] stdout: "
323
- f"{result.stdout[:200] if result.stdout else '(empty)'}"
324
- )
325
- logger.debug(
326
- f"[{token}] stderr: "
327
- f"{result.stderr[:200] if result.stderr else '(empty)'}"
328
- )
329
-
330
- with self.lock:
331
- if token in self.commands:
332
- self.commands[token].update(
333
- {
334
- "status": "completed",
335
- "stdout": result.stdout,
336
- "stderr": result.stderr,
337
- "exit_code": result.returncode,
338
- "execution_time": execution_time,
339
- }
340
- )
341
-
342
- except FileNotFoundError as e:
343
- execution_time = time.time() - start_time
344
- logger.error(f"[{token}] PowerShell not found: {e}")
345
- with self.lock:
346
- if token in self.commands:
347
- self.commands[token].update(
348
- {
349
- "status": "completed",
350
- "stdout": "",
351
- "stderr": f"PowerShell not found: {e}",
352
- "exit_code": -2,
353
- "execution_time": execution_time,
354
- "timeout_occurred": False,
355
- }
356
- )
357
- except subprocess.TimeoutExpired:
358
- execution_time = time.time() - start_time
359
- logger.warning(f"[{token}] Command timed out after {timeout}s")
360
- with self.lock:
361
- if token in self.commands:
362
- self.commands[token].update(
363
- {
364
- "status": "completed",
365
- "stdout": "",
366
- "stderr": (
367
- f"Command timed out after {timeout} seconds"
368
- ),
369
- "exit_code": -1,
370
- "execution_time": execution_time,
371
- "timeout_occurred": True,
372
- }
373
- )
374
- except Exception as e:
375
- execution_time = time.time() - start_time
376
- logger.error(f"[{token}] Command failed with exception: {e}")
377
- with self.lock:
378
- if token in self.commands:
379
- self.commands[token].update(
380
- {
381
- "status": "completed",
382
- "stdout": "",
383
- "stderr": str(e),
384
- "exit_code": -1,
385
- "execution_time": execution_time,
386
- "timeout_occurred": False,
387
- }
388
- )
389
-
390
- def query_command_status(self, token: str) -> Dict[str, Any]:
391
- """
392
- 查询命令执行状态
393
-
394
- Args:
395
- token: 命令的token
396
-
397
- Returns:
398
- 包含命令状态的字典
399
- """
400
- logger.debug(f"Querying status for token: {token}")
401
-
402
- with self.lock:
403
- if token not in self.commands:
404
- logger.warning(f"Token not found: {token}")
405
- return {
406
- "token": token,
407
- "status": "not_found",
408
- "message": "Token not found",
409
- }
410
-
411
- cmd_info = self.commands[token].copy()
412
- logger.debug(f"[{token}] Status: {cmd_info['status']}")
413
-
414
- if cmd_info["status"] == "running":
415
- return {"token": cmd_info["token"], "status": "running"}
416
- elif cmd_info["status"] in ["completed", "pending"]:
417
- return {
418
- "token": cmd_info["token"],
419
- "status": cmd_info["status"],
420
- "exit_code": cmd_info["exit_code"],
421
- "stdout": cmd_info["stdout"],
422
- "stderr": cmd_info["stderr"],
423
- "execution_time": cmd_info["execution_time"],
424
- "timeout_occurred": cmd_info["timeout_occurred"],
425
- }
426
- else:
427
- return cmd_info
1
+ """
2
+ winterm服务模块 - 异步执行Windows终端命令服务
3
+ """
4
+
5
+ import subprocess
6
+ import threading
7
+ import uuid
8
+ import time
9
+ import os
10
+ import logging
11
+ from datetime import datetime
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)
39
+
40
+
41
+ def setup_logging(level: int = logging.INFO) -> None:
42
+ """
43
+ 配置日志输出
44
+
45
+ Args:
46
+ level: 日志级别,默认 INFO
47
+
48
+ 日志输出位置:
49
+ 1. 控制台 (stderr)
50
+ 2. 文件: %TEMP%/winterm-mcp.log 或 /tmp/winterm-mcp.log
51
+
52
+ 可通过环境变量配置:
53
+ - WINTERM_LOG_LEVEL: 日志级别 (DEBUG/INFO/WARNING/ERROR/CRITICAL)
54
+ - WINTERM_LOG_FILE: 自定义日志文件路径
55
+ """
56
+ import tempfile
57
+
58
+ formatter = logging.Formatter(
59
+ "[%(asctime)s] [%(name)s] [%(levelname)s] %(message)s",
60
+ datefmt="%Y-%m-%d %H:%M:%S"
61
+ )
62
+
63
+ console_handler = logging.StreamHandler()
64
+ console_handler.setFormatter(formatter)
65
+ logger.addHandler(console_handler)
66
+
67
+ log_file = os.environ.get("WINTERM_LOG_FILE")
68
+ if not log_file:
69
+ log_file = os.path.join(tempfile.gettempdir(), "winterm-mcp.log")
70
+
71
+ try:
72
+ file_handler = logging.FileHandler(log_file, encoding="utf-8")
73
+ file_handler.setFormatter(formatter)
74
+ logger.addHandler(file_handler)
75
+ logger.info(f"Log file: {log_file}")
76
+ except Exception as e:
77
+ logger.warning(f"Failed to create log file {log_file}: {e}")
78
+
79
+ logger.setLevel(level)
80
+
81
+ env_level = os.environ.get("WINTERM_LOG_LEVEL", "").upper()
82
+ if env_level in ("DEBUG", "INFO", "WARNING", "ERROR", "CRITICAL"):
83
+ logger.setLevel(getattr(logging, env_level))
84
+
85
+
86
+ def get_version() -> str:
87
+ """
88
+ 获取 winterm-mcp 版本号
89
+
90
+ Returns:
91
+ 版本号字符串
92
+ """
93
+ return __version__
94
+
95
+
96
+ class CommandService:
97
+ """
98
+ 异步命令执行服务类,管理所有异步命令的执行和状态
99
+ """
100
+
101
+ def __init__(self):
102
+ self._store = CommandStore()
103
+ self._powershell_path: Optional[str] = None
104
+ self._cmd_path: Optional[str] = None
105
+
106
+ def _get_powershell_path(self) -> str:
107
+ """
108
+ 获取 PowerShell 可执行文件路径(带缓存)
109
+
110
+ Returns:
111
+ PowerShell 可执行文件的绝对路径
112
+
113
+ Raises:
114
+ FileNotFoundError: 如果找不到 PowerShell
115
+ """
116
+ if self._powershell_path is None:
117
+ self._powershell_path = find_powershell()
118
+ logger.debug(f"PowerShell path cached: {self._powershell_path}")
119
+ return self._powershell_path
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
+
136
+ def run_command(
137
+ self,
138
+ command: str,
139
+ executable: Optional[str] = None,
140
+ args: Optional[List[str]] = None,
141
+ shell_type: Literal["powershell", "cmd", "executable"] = "executable",
142
+ timeout: int = DEFAULT_TIMEOUT,
143
+ working_directory: Optional[str] = None,
144
+ enable_streaming: bool = False,
145
+ ) -> str:
146
+ """
147
+ 异步运行命令
148
+
149
+ Args:
150
+ command: 要执行的命令
151
+ executable: 可执行文件路径
152
+ args: 可执行文件参数
153
+ shell_type: Shell 类型 (powershell/cmd/executable)
154
+ timeout: 超时时间(秒)
155
+ working_directory: 工作目录
156
+ enable_streaming: 启用实时流式输出
157
+
158
+ Returns:
159
+ 命令执行的token
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
+
173
+ token = str(uuid.uuid4())
174
+
175
+ logger.info(
176
+ f"Submitting command: token={token}, shell={shell_type}, "
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,
201
+ )
202
+
203
+ self._store.add_command(token, cmd_info)
204
+
205
+ thread = threading.Thread(
206
+ target=self._execute_command,
207
+ args=(token, command, executable, args, shell_type, timeout, working_directory, enable_streaming),
208
+ )
209
+ thread.daemon = True
210
+ thread.start()
211
+
212
+ return token
213
+
214
+ def _execute_command(
215
+ self,
216
+ token: str,
217
+ command: str,
218
+ executable: Optional[str],
219
+ args: Optional[List[str]],
220
+ shell_type: str,
221
+ timeout: int,
222
+ working_directory: Optional[str],
223
+ enable_streaming: bool,
224
+ ):
225
+ """
226
+ 在单独线程中执行命令
227
+ """
228
+ try:
229
+ start_time = time.time()
230
+ logger.debug(f"[{token}] Starting command execution...")
231
+
232
+ self._store.update_command(token, status="running")
233
+
234
+ encoding = "gbk"
235
+
236
+ if shell_type == "powershell":
237
+ ps_path = self._get_powershell_path()
238
+ logger.info(f"[{token}] Using PowerShell: {ps_path}")
239
+ cmd_args = [
240
+ ps_path,
241
+ "-NoProfile",
242
+ "-NoLogo",
243
+ "-NonInteractive",
244
+ "-ExecutionPolicy",
245
+ "Bypass",
246
+ "-Command",
247
+ command,
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]
253
+ else:
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
261
+
262
+ logger.debug(f"[{token}] Executing: {cmd_args}")
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(
380
+ cmd_args,
381
+ cols=PTY_COLS,
382
+ rows=PTY_ROWS,
383
+ cwd=cwd,
384
+ env=env or os.environ,
385
+ )
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 ""
434
+ execution_time = time.time() - start_time
435
+
436
+ logger.info(
437
+ f"[{token}] PTY command completed: exit_code={exit_code}, "
438
+ f"time={execution_time:.3f}s"
439
+ )
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
+ )
455
+
456
+ except Exception as e:
457
+ execution_time = time.time() - start_time
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
+ )
468
+
469
+ def query_command_status(self, token: str) -> Dict[str, Any]:
470
+ """
471
+ 查询命令执行状态
472
+
473
+ Args:
474
+ token: 命令的token
475
+
476
+ Returns:
477
+ 包含命令状态的字典
478
+ """
479
+ logger.debug(f"Querying status for token: {token}")
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