Files
xiaoli-cli/plugins/terminal.py
T
xiaoli-cli bot dfe21298b9 release: v5.4.2 - terminal模拟器 + Operit桥接
新增插件:
- terminal: 持久交互式终端,纯标准库,全平台,13个测试
- operit_bridge: Operit AI工具格式桥接,47个脚本288个工具,12个测试

其他:
- plugins/operit/: 47个Operit官方脚本
- 更新plugin_registry.json/plugins_config.py
- 更新about.txt/CHANGELOG.md至v5.4.2
2026-06-23 08:50:15 +00:00

603 lines
22 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
终端模拟器插件 — 纯标准库,Windows/Linux/macOS 通用
===================================================
持久交互式终端会话,AI 可以像人一样打开终端、敲命令、
看输出、继续交互,支持 Python REPL、SSH、top 等。
"""
import os
import re
import sys
import time
import json
import uuid
import shutil
import signal
import threading
import subprocess
import platform
from collections import deque
# ═══════════════════════════════════════════════════════════════
# ToolResult 轻量替代(避免依赖 xcli_core)
# ═══════════════════════════════════════════════════════════════
class _R:
"""极简返回值"""
@staticmethod
def ok(data): return str(data)
@staticmethod
def fail(msg): return f"[错误] {msg}"
# ═══════════════════════════════════════════════════════════════
# 单个终端会话
# ═══════════════════════════════════════════════════════════════
_IS_WIN = platform.system() == "Windows"
class TerminalSession:
"""
一个持久的交互式终端会话。
纯标准库实现:subprocess.Popen + threading.Thread,
不依赖 pty、pty.openpty、select.poll 等 Unix 特有 API。
"""
_SENTINEL = "<<<XLI_DONE_"
def __init__(self, session_id: str, shell: str = "", cwd: str = "",
env: dict = None, label: str = ""):
self.id = session_id
self.label = label or session_id
self.shell = shell or self._default_shell()
self.cwd = cwd or os.getcwd()
self.env = env or {}
self.created_at = time.time()
self.last_active = time.time()
self._output_buf = deque(maxlen=5000)
self._raw_buf = ""
self._last_cmd_output = "" # 最近一次 send_cmd 的结果
self._lock = threading.Lock()
self._proc = None
self._reader = None
self._start()
@staticmethod
def _default_shell():
if _IS_WIN:
return os.environ.get("COMSPEC", "cmd.exe")
for sh in ["/bin/bash", "/bin/sh", "/usr/bin/bash"]:
if os.path.isfile(sh):
return sh
return "/bin/sh"
def _start(self):
env = {**os.environ, **self.env}
if _IS_WIN:
shell_args = [self.shell, "/K", "chcp 65001 >nul"]
creationflags = subprocess.CREATE_NO_WINDOW
else:
env["TERM"] = "dumb"
env["NO_COLOR"] = "1"
env["PROMPT_COMMAND"] = ""
env["PYTHONUNBUFFERED"] = "1" # Python REPL 不缓冲
creationflags = 0
# 只有 bash 才加 --norc --noprofile
shell_base = os.path.basename(self.shell)
if shell_base in ("bash", "bash.exe"):
env["PS1"] = ""
shell_args = [self.shell, "--norc", "--noprofile"]
elif "python" in shell_base:
# Python 需要 -i 才能管道下交互
shell_args = [self.shell, "-i"]
else:
shell_args = [self.shell]
try:
self._proc = subprocess.Popen(
shell_args,
stdin=subprocess.PIPE,
stdout=subprocess.PIPE,
stderr=subprocess.STDOUT,
cwd=self.cwd,
env=env,
bufsize=0,
creationflags=creationflags if _IS_WIN else 0,
)
except FileNotFoundError:
raise RuntimeError(f"Shell 不存在: {self.shell}")
except Exception as e:
raise RuntimeError(f"启动终端失败: {e}")
self._reader = threading.Thread(target=self._read_loop, daemon=True)
self._reader.start()
time.sleep(0.3)
self.last_active = time.time()
def _read_loop(self):
"""后台持续读取子进程 stdout"""
proc = self._proc
fd = proc.stdout.fileno()
while proc.poll() is None:
try:
chunk = os.read(fd, 4096)
if not chunk:
break
text = chunk.decode("utf-8", errors="replace")
with self._lock:
self._raw_buf += text
for line in text.split("\n"):
self._output_buf.append(line)
except (OSError, ValueError):
break
def send(self, text: str, wait: float = 0.3, timeout: float = 15) -> str:
"""发送文本,返回后续输出"""
if not self.alive():
return "[错误] 终端进程已退出"
self.last_active = time.time()
with self._lock:
self._raw_buf = ""
try:
self._proc.stdin.write(text.encode("utf-8"))
self._proc.stdin.flush()
except (OSError, BrokenPipeError) as e:
return f"[错误] 发送失败: {e}"
return self._collect(wait, timeout)
def send_cmd(self, command: str, timeout: float = 15) -> str:
"""
发送命令并可靠等待输出结束。
用临时文件做哨兵,避免 stdout 混入提示符。
"""
if not self.alive():
return "[错误] 终端进程已退出"
self.last_active = time.time()
with self._lock:
self._raw_buf = ""
# 哨兵临时文件
marker = f"/tmp/.xli_{uuid.uuid4().hex[:8]}" if not _IS_WIN else f"%TEMP%\\.xli_{uuid.uuid4().hex[:8]}"
# 发送: 命令 + 哨兵文件创建
payload = f"{command}\n{'touch' if not _IS_WIN else 'type nul >'} {marker}\n"
try:
self._proc.stdin.write(payload.encode("utf-8"))
self._proc.stdin.flush()
except (OSError, BrokenPipeError) as e:
return f"[错误] 发送失败: {e}"
# 等待哨兵文件出现
real_marker = marker if not _IS_WIN else os.path.expandvars(marker)
deadline = time.time() + timeout
while time.time() < deadline:
if os.path.exists(real_marker):
break
time.sleep(0.03)
# 给 stdout 一点时间把剩余输出冲完
time.sleep(0.1)
# 收集输出
with self._lock:
out = self._raw_buf
self._raw_buf = ""
# 清理哨兵文件
try:
os.unlink(real_marker)
except OSError:
pass
# 过滤掉命令回显和哨兵行
lines = out.split("\n")
result = []
for line in lines:
s = line.strip()
if s == command.strip():
continue
if ".xli_" in s:
continue
if s.startswith("touch ") or s.startswith("type nul"):
continue
result.append(line)
out = self._clean("\n".join(result))
with self._lock:
self._last_cmd_output = out
return out
def _collect(self, wait: float, timeout: float) -> str:
"""收集输出"""
time.sleep(wait)
deadline = time.time() + timeout
buf = ""
while time.time() < deadline:
with self._lock:
new = self._raw_buf
self._raw_buf = ""
buf += new
if new:
time.sleep(0.05)
else:
time.sleep(0.1)
with self._lock:
new = self._raw_buf
self._raw_buf = ""
buf += new
if not new:
break
return self._clean(buf)
def _clean(self, text: str) -> str:
if not text:
return ""
# 去 ANSI 转义
text = re.sub(r'\x1b\[[0-9;]*[a-zA-Z]', '', text)
text = re.sub(r'\x1b\][^\x07]*\x07', '', text)
text = re.sub(r'[\x00-\x08\x0b\x0c\x0e-\x1f]', '', text)
text = text.replace('\r\n', '\n').replace('\r', '\n')
text = re.sub(r'\n{4,}', '\n\n\n', text)
return text.strip()
def alive(self) -> bool:
return self._proc is not None and self._proc.poll() is None
def recent(self, lines: int = 50) -> str:
"""返回原始输出缓冲的最近几行"""
with self._lock:
buf = list(self._output_buf)[-lines:]
return self._clean("\n".join(buf))
def status(self) -> dict:
with self._lock:
lines = len(self._last_cmd_output.split('\n')) if self._last_cmd_output else 0
return {
"id": self.id,
"label": self.label,
"shell": self.shell,
"cwd": self.cwd,
"alive": self.alive(),
"pid": self._proc.pid if self._proc else None,
"uptime": round(time.time() - self.created_at, 1),
"idle": round(time.time() - self.last_active, 1),
"lines": lines,
}
def close(self):
try:
if self._proc and self._proc.poll() is None:
try:
self._proc.stdin.write(b"exit\n")
self._proc.stdin.flush()
except Exception:
pass
time.sleep(0.2)
if self._proc.poll() is None:
self._proc.terminate()
try:
self._proc.wait(timeout=3)
except subprocess.TimeoutExpired:
self._proc.kill()
except Exception:
pass
self._proc = None
# ═══════════════════════════════════════════════════════════════
# Liugin 插件
# ═══════════════════════════════════════════════════════════════
class Liugin:
"""终端模拟器 — 纯标准库,全平台"""
def __init__(self):
self.cli = None
self._sessions: dict = {}
self._default_id = "main"
def set_cli(self, cli):
self.cli = cli
def get_tool_info(self):
return {
"name": "terminal",
"description": "交互式终端模拟器 — 持久 Shell 会话,纯标准库实现,"
"Windows/Linux/macOS 通用,支持 Python REPL、SSH 等交互式程序",
"keywords": [
"终端", "terminal", "shell", "交互", "会话", "REPL",
"持久", "cmd", "powershell", "bash", "pty", "windows",
],
"usage": (
"terminal <操作> [参数]\n\n"
"操作:\n"
" open [shell] [label] - 打开新终端 (默认: 系统 shell)\n"
" send <命令> [--timeout N] - 发送命令并获取输出\n"
" input <文本> - 发送纯输入 (不换行)\n"
" read [行数] - 读取最近输出\n"
" switch <ID> - 切换会话\n"
" list - 列出所有会话\n"
" close [ID] - 关闭会话\n"
" status - 当前状态\n"
" cwd [路径] - 获取/切换目录\n"
" env <KEY> [VALUE] - 环境变量\n"
" keys <按键名> - 发送特殊按键\n\n"
"示例:\n"
" terminal open - 打开默认 shell\n"
" terminal open python3 \"Python\" - 打开 Python REPL\n"
" terminal send dir - Windows 列目录\n"
" terminal send ls -la - Linux 列目录\n"
" terminal send ssh user@host - SSH 连接\n"
" terminal keys Ctrl+C - 中断程序\n"
" terminal close - 关闭当前会话"
),
}
def get_mcp_definition(self):
return {
"name": "terminal",
"description": "交互式终端模拟器 — 持久 Shell 会话,支持交互式程序",
"inputSchema": {
"type": "object",
"properties": {
"action": {
"type": "string",
"enum": ["open", "send", "input", "read", "switch",
"list", "close", "status", "cwd", "env", "keys"],
"description": "操作类型",
},
"command": {"type": "string", "description": "命令或文本"},
"session_id": {"type": "string", "description": "会话 ID"},
"shell": {"type": "string", "description": "Shell 路径"},
"label": {"type": "string", "description": "会话标签"},
"timeout": {"type": "number", "description": "超时秒数 (默认 15)"},
"lines": {"type": "integer", "description": "读取行数"},
"value": {"type": "string", "description": "环境变量值"},
"keys": {"type": "string", "description": "特殊按键"},
},
"required": ["action"],
},
}
def convert_mcp_args(self, arguments):
action = arguments.get("action", "")
parts = [action]
cmd = arguments.get("command", "")
if cmd:
parts.append(cmd)
sid = arguments.get("session_id", "")
if sid:
parts.append(f"--sid {sid}")
shell = arguments.get("shell", "")
if shell:
parts.append(f"--shell {shell}")
label = arguments.get("label", "")
if label:
parts.append(f"--label {label}")
timeout = arguments.get("timeout")
if timeout is not None:
parts.append(f"--timeout {timeout}")
lines = arguments.get("lines")
if lines is not None:
parts.append(f"--lines {lines}")
value = arguments.get("value")
if value is not None:
parts.append(value)
keys = arguments.get("keys")
if keys:
parts.append(keys)
return " ".join(parts)
_KEY_MAP = {
"ctrl+c": "\x03", "ctrl+d": "\x04", "ctrl+z": "\x1a",
"ctrl+l": "\x0c", "ctrl+a": "\x01", "ctrl+e": "\x05",
"ctrl+k": "\x0b", "ctrl+u": "\x15", "ctrl+w": "\x17",
"tab": "\t", "enter": "\n",
"up": "\x1b[A", "down": "\x1b[B",
"right": "\x1b[C", "left": "\x1b[D",
"home": "\x1b[H", "end": "\x1b[F",
"backspace": "\x7f", "esc": "\x1b",
"pageup": "\x1b[5~", "pagedown": "\x1b[6~",
}
def handle(self, args: str) -> str:
parts = args.strip().split(maxsplit=1)
if not parts:
return self._help()
action = parts[0].lower()
rest = parts[1].strip() if len(parts) > 1 else ""
h = {
"open": self._h_open, "send": self._h_send,
"input": self._h_input, "read": self._h_read,
"switch": self._h_switch, "list": self._h_list,
"close": self._h_close, "status": self._h_status,
"cwd": self._h_cwd, "env": self._h_env, "keys": self._h_keys,
}.get(action)
if not h:
return f"错误: 不支持 '{action}'\n{self._help()}"
try:
return h(rest)
except Exception as e:
return f"终端错误: {e}"
def _help(self):
return (
"终端模拟器:\n"
" open [shell] [label] - 打开新会话\n"
" send <命令> - 执行命令\n"
" input <文本> - 纯输入\n"
" read [行数] - 读输出\n"
" switch/list/close - 会话管理\n"
" cwd/env/keys - 目录/变量/按键"
)
def _get(self, sid: str = "") -> TerminalSession:
sid = sid or self._default_id
if sid not in self._sessions:
self._sessions[sid] = TerminalSession(sid)
return self._sessions[sid]
def _opts(self, rest: str) -> dict:
opts = {}
for m in re.finditer(r'--(\w+)\s+(\S+)', rest):
opts[m.group(1)] = m.group(2)
opts["_"] = re.sub(r'--\w+\s+\S+', '', rest).strip()
return opts
def _h_open(self, rest: str) -> str:
o = self._opts(rest)
shell = o.get("shell", "")
label = o.get("label", "")
sid = o.get("sid", "")
pos = o.get("_", "")
if pos and not shell:
ps = pos.split()
shell = ps[0]
if len(ps) > 1 and not label:
label = ps[1]
if not sid:
sid = f"s{len(self._sessions) + 1}" if self._sessions else self._default_id
try:
s = TerminalSession(sid, shell=shell, label=label)
self._sessions[sid] = s
self._default_id = sid # 自动切换到新会话
info = s.status()
return (
f"✓ 终端已打开\n"
f" 会话: {sid} ({info['label']})\n"
f" Shell: {info['shell']}\n"
f" PID: {info['pid']}\n"
f" 输出:\n{s.recent(10) or '(空)'}"
)
except RuntimeError as e:
return f"✗ 打开失败: {e}"
def _h_send(self, rest: str) -> str:
if not rest:
return "错误: 请提供命令"
o = self._opts(rest)
cmd = o.get("_", rest).strip()
timeout = float(o.get("timeout", "15"))
if not cmd:
cmd = re.sub(r'--timeout\s+\S+', '', rest).strip()
if not cmd:
return "错误: 请提供命令"
s = self._get()
out = s.send_cmd(cmd, timeout=timeout)
tag = "✓" if s.alive() else "✗"
return f"{tag} {cmd}\n{out}"
def _h_input(self, rest: str) -> str:
if not rest:
return "错误: 请提供文本"
s = self._get()
# 加换行让 REPL 执行
out = s.send(rest + "\n", wait=0.5)
with s._lock:
s._last_cmd_output = out
return f"← 输入: {rest}\n{out}"
def _h_read(self, rest: str) -> str:
"""读取输出 — 优先用缓存,否则从原始缓冲读"""
s = self._get()
with s._lock:
cached = s._last_cmd_output
if cached:
return cached
# REPL 场景:从原始缓冲读
return s.recent(30) or "(无输出)"
def _h_switch(self, rest: str) -> str:
sid = rest.strip()
if not sid:
return "错误: 请提供会话 ID"
if sid not in self._sessions:
return f"错误: 不存在 '{sid}'。可用: {', '.join(self._sessions)}"
self._default_id = sid
s = self._sessions[sid]
return f"✓ 已切换到 {sid} ({s.label})"
def _h_list(self, _: str) -> str:
if not self._sessions:
return "暂无终端。用 terminal open 打开。"
lines = [f"终端 ({len(self._sessions)} 个):", ""]
for sid, s in self._sessions.items():
st = s.status()
cur = " ← 当前" if sid == self._default_id else ""
dot = "●" if st["alive"] else "○"
lines.append(
f" {dot} {sid}: {st['label']} "
f"[{st['shell']}] "
f"空闲{st['idle']}s "
f"{st['lines']}行{cur}"
)
return "\n".join(lines)
def _h_close(self, rest: str) -> str:
sid = rest.strip() or self._default_id
if sid not in self._sessions:
return f"错误: 不存在 '{sid}'"
s = self._sessions.pop(sid)
s.close()
if self._default_id == sid:
self._default_id = next(iter(self._sessions), "main")
return f"✓ 终端 {sid} ({s.label}) 已关闭"
def _h_status(self, _: str) -> str:
s = self._get()
st = s.status()
return (
f"终端状态:\n"
f" 会话: {st['id']} ({st['label']})\n"
f" Shell: {st['shell']}\n"
f" PID: {st['pid']}\n"
f" 目录: {st['cwd']}\n"
f" 状态: {'运行中' if st['alive'] else '已退出'}\n"
f" 运行: {st['uptime']}s 空闲: {st['idle']}s\n"
f" 输出: {st['lines']}行"
)
def _h_cwd(self, rest: str) -> str:
s = self._get()
target = rest.strip()
if not target:
return f"当前目录: {s.send_cmd('cd' if _IS_WIN else 'pwd', timeout=5)}"
cmd = f"cd /d {target} && cd" if _IS_WIN else f"cd {target} && pwd"
out = s.send_cmd(cmd, timeout=5)
if out and not out.startswith("[错误"):
s.cwd = out.strip().split("\n")[-1].strip()
return f"✓ 目录: {s.cwd}"
return f"切换失败: {out}"
def _h_env(self, rest: str) -> str:
s = self._get()
parts = rest.strip().split(maxsplit=1)
if not parts:
return s.send_cmd("set" if _IS_WIN else "env | head -30", timeout=5)
key = parts[0]
if len(parts) == 1:
var = f"%{key}%" if _IS_WIN else f"${key}"
return s.send_cmd(f"echo {var}", timeout=5)
val = parts[1]
cmd = f"set {key}={val}" if _IS_WIN else f"export {key}={val}"
s.send_cmd(cmd, timeout=5)
return f"✓ {key}={val}"
def _h_keys(self, rest: str) -> str:
if not rest:
return f"可用: {', '.join(self._KEY_MAP)}"
s = self._get()
name = rest.strip().lower()
seq = self._KEY_MAP.get(name)
if not seq:
return f"错误: 未知按键 '{name}'\n可用: {', '.join(self._KEY_MAP)}"
out = s.send(seq, wait=0.3)
return f"← {name}\n{out}"