CLI 这个东西,用得越多就越觉得它像个“孤岛”。明明功能很强,但真正要用它的人,往往得坐在终端前面,记住一堆参数,还得会看输出。我一直在想,为什么不能让任何一个命令行工具,都能被其他系统随意调用、自动编排、甚至变成图形界面?后来我做了个小项目,叫 CLI-Anything,思路很简单:把任何 CLI 命令包一层,统一暴露成 REST API、WebSocket 流、桌面快捷键这些通用接口。这篇文章就把这个项目的设计思路、实现细节和踩过的坑完整讲一遍,适合那些天天跟命令行打交道、又想让终端能力“走出去”的开发者参考。
1. 内容整体设计与思路拆解
1.1 CLI 的本质是什么,为什么需要一层“适配器”
一个命令行程序,本质上就是一个输入输出黑盒:你给它命令行参数,喂它标准输入,它往标准输出吐结果,往标准错误输出吐日志,最后用一个退出码告诉你成没成功。这个模型简单可靠,几十年没变过。但问题也出在简单上——它默认服务对象是“坐在终端前的人类”,而不是其他程序。
举个例子,运维同学写了个脚本check_disk.sh,平时在服务器上跑得好好的。突然有一天,业务方想要一个磁盘监控接口,让前端页面能实时看到;又有一天,他想在钉钉群里发一条命令触发检查。这时候如果每次都要重新写一遍逻辑,或者用 SSH 去服务器上执行命令再抓文本,就会非常痛苦。CLI-Anything 想解决的就是这个场景:你只写一次命令怎么跑,剩下的转发、格式化、并发控制、超时管理,全交给这一层适配器。
这个思路其实很像生活中常见的转换插头。你有个欧标的充电器,到了不同的国家,不需要把充电器拆了重造,只需要换一个插座转换头,就能适配当地的电源标准。CLI-Anything 就是那个转换头,命令本身不用改,外面想接什么协议就插什么协议。
1.2 方案选型背后的三个关键决策
我第一版其实走了一条弯路:给每个 CLI 工具单独写一个 Python 脚本,用subprocess去调,然后每个脚本来回复制粘贴处理超时和日志的代码。实现到第三个命令的时候,我就发现维护成本开始失控。任何一个公共逻辑的改动,都要同步到所有脚本,而且每个脚本里的参数解析方式还不完全一致。
所以第二版我下了三个决心。
第一个决心是配置驱动,而不是代码驱动。把命令的参数、路径、超时、是否需要交互这些信息全部写进 YAML 配置文件里。新增一个命令时,只需要加一段配置,不需要写任何胶水代码。这样哪怕是不太会写 Python 的人,也能通过配置文件接入新工具。
第二个决心是先抓住中间表示层。不管外面是 REST、WebSocket 还是未来可能出现的什么新协议,我都先把终端交互过程抽象成一个统一的“命令会话”对象。这个对象负责处理进程生命周期、输入输出流、退出码,至于外部用 HTTP 还是 WebSocket 来对接,都只是对同一个会话对象的不同视图。
第三个决心是流式优先。很多 CLI 工具不是一口气返回结果,而是像tail -f、ffmpeg那样源源不断输出。这些工具如果只是等命令结束再一次性返回,体验会非常差。所以整个适配器从设计之初就必须支持“边执行边推送”的数据流,这个决定直接影响了我后面选择用什么技术栈。
2. 核心细节解析与实操要点
2.1 进程管理与伪终端:为什么必须选 pty
要适配“任何” CLI,最稳妥的方式是启动一个真正的子进程。Python 里最基础的工具是subprocess.Popen,可以直接捕获标准输出和标准错误。但用了一段时间我发现,它拿不到两类程序的输出:一类是会检测“当前是否在终端里”来决定行为模式的程序,比如很多工具只有在 TTY 下才会输出彩色信息和进度条;另一类是交互式的程序,比如python解释器、ssh、ftp,它们需要读写一个终端设备才能正常工作。
解决方案是用伪终端(PTY)。伪终端这个东西很奇妙,它模拟了一个真实终端设备,让子进程认为自己在跟人说话,实际上在跟我们的适配器说话。Python 标准库里有pty模块,配合subprocess可以把子进程的输入输出挂到一个伪终端上。选 pty 还有另一个好处:很多程序在管道模式下会做块缓冲,明明输出了内容却不 flush,在伪终端下它们通常会改成行缓冲,我们就能更快拿到输出。
不过 pty 也带来一个新问题:它默认会把子进程收到的输入原样回显(echo)到输出里。如果你向ssh发送了一个密码,输出里就会重复出现这份密码。这个问题我会在下一节详细讲。
2.2 输出流处理:把“终端屏幕”变成事件流
在一个真实终端里,屏幕显示的是一个二维平面,有光标、有滚动区域、有各种控制字符。如果适配器想把这些信息通过 WebSocket 推给浏览器,不能直接把原始字节流发过去——浏览器看到了只会乱码。我采取的办法是把“终端屏幕”抽象成一组事件:
| 事件类型 | 触发时机 | 示例场景 |
|---|---|---|
line | 收到一行完整文本(去掉控制字符) | 普通日志输出 |
data | 收到无法按行切分的原始片段 | 进度条刷新、密码输入提示 |
exit | 子进程结束 | 命令退出 |
error | 进程启动失败或超时 | 路径不存在、执行超时 |
对于大多数工具,我只会保留line和exit两类事件,因为它们足以覆盖 90% 的自动化场景。对于少数特殊工具,比如进度条,再单独启用data事件。
在代码实现上,我用asyncio的事件循环来驱动一个 reader 协程。它从 pty 的文件描述符里读数据,按行拆分,把每一行送到一个队列里。外部 API 层只需从这个队列里异步读取,就能做到边执行边推送。
2.3 配置系统:像写“菜谱”一样定义命令
CLI-Anything 的配置我选的是 YAML。配置结构长成这样:
commands: disk: cmd: /usr/local/bin/check_disk.sh args: - --path - "{{path}}" mode: once timeout: 30 tail_log: cmd: tail args: - -f - /var/log/app.log mode: stream timeout: 0这里面有几个关键设计点。第一个是参数模板,用{{path}}这种占位符来表示运行时传入的参数。外部 API 收到请求后,把请求参数填充到模板里,再拼接成完整的命令行。第二个是模式区分,once表示命令运行完就结束,stream表示命令需要持续输出,适配器会一直保持会话。第三个是超时语义,timeout: 0表示永不超时,通常用于流式命令。
我还设计了一个简单的类型转换规则:如果请求参数是 JSON 里的true,但命令行需要字符串"true",配置里可以加一个type: string来声明。这类细节看起来小,但实际用起来非常影响体验。
3. 实操过程与核心环节实现
3.1 最小可用原型的代码骨架
环境准备很简单:Python 3.10 以上,装fastapi、uvicorn、pyyaml、websockets。不需要数据库,不需要消息队列,因为 CLI-Anything 的核心逻辑是进程管理,而不是业务编排。
第一步,我先写一个CommandSession类,它是整个系统的核心单元。这个类负责启动子进程、管理伪终端、读取输出、等待退出。
import asyncio import os import pty import subprocess class CommandSession: def __init__(self, name, cmd_list, timeout): self.name = name self.cmd_list = cmd_list self.timeout = timeout self.proc = None self.fd = None self.queue = asyncio.Queue() self.exit_code = None async def start(self): master_fd, slave_fd = pty.openpty() self.proc = subprocess.Popen( self.cmd_list, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, close_fds=True, ) os.close(slave_fd) self.fd = master_fd loop = asyncio.get_running_loop() loop.add_reader(self.fd, self._read_ready) def _read_ready(self): try: data = os.read(self.fd, 4096) except OSError: self._terminate() return if not data: self._terminate() return text = data.decode("utf-8", errors="replace") lines = text.splitlines() for line in lines: self.queue.put_nowait({"type": "line", "data": line}) # 处理末尾残留 tail = text.splitlines(keepends=True)[-1:] if text else [] if tail and not tail[0].endswith("\n"): self.queue.put_nowait({"type": "partial", "data": tail[0]}) def _terminate(self): if self.fd: loop = asyncio.get_running_loop() loop.remove_reader(self.fd) os.close(self.fd) self.fd = None if self.proc: self.exit_code = self.proc.poll() self.queue.put_nowait({"type": "exit", "code": self.exit_code})这段代码里有几个坑。我在第一版时直接读self.proc.stdout,结果遇到很多“卡住”的现象,因为子进程把输出写进管道缓冲区后没有退出,而管道缓冲区又不够大。换成 pty 后,这个问题基本消失,因为 pty 本身就模拟了一个可交互的字符设备。
3.2 关掉回声:解决密码重复显示的问题
pty 默认开启回显,意味着子进程接收到的输入会原样出现在输出流里。对普通命令没什么影响,但如果是交互式命令,比如ssh输入密码,或者ftp输入用户名,这些输入会被打出来,既难看也容易泄露。
解决办法是在启动子进程之前,把 pty 的终端属性设置一下,关闭ECHO标志。Python 里可以用termios模块操作slave_fd对应的终端:
import termios attrs = termios.tcgetattr(slave_fd) # 第 3 个元素对应 lflag,把 ECHO 位关掉 attrs[3] &= ~termios.ECHO termios.tcsetattr(slave_fd, termios.TCSANOW, attrs)注意这里的顺序:必须先设置好终端属性,再启动子进程。如果先Popen再设置,子进程可能已经读完了初始终端属性,关闭回声就不生效了。这是我实际调试中踩过的一个典型时序问题。
关掉全局回声之后,普通的非交互命令不受影响,因为它们的输出本来就不是回显。但那些需要用户输入的交互命令会变得“看不到输入内容”,这其实是正确行为——真实终端在输入密码时也是不显示星号的,只显示空白。
3.3 超时控制与进程组清理
任何适配器都必须处理超时。一个卡死的命令不能永远占用资源。我在CommandSession里加入超时逻辑:启动后,启动一个计时器,如果超时命令还没结束,就把整个进程组杀掉。
为什么要杀进程组?因为很多命令会派生子进程,比如bash -c "sleep 100 & wait",只杀掉主进程,子进程会变成孤儿继续跑。正确做法是启动时让子进程成为新的进程组组长,超时后对这个进程组整体发信号。
import signal async def start(self): self.proc = subprocess.Popen( self.cmd_list, stdin=slave_fd, stdout=slave_fd, stderr=slave_fd, start_new_session=True, # 让子进程成为新会话首领 close_fds=True, ) async def _timeout_handler(self): await asyncio.sleep(self.timeout) if self.proc and self.proc.poll() is None: os.killpg(os.getpgid(self.proc.pid), signal.SIGKILL)这里选定超时值也有一点讲究。对于交互式命令,超时应该是指“等待下一次输出”的空闲超时,而不是整个会话的总时长;对于批处理命令,超时则是指总量时长。为了简单,CLI-Anything 第一版只做总量超时,把配置写成timeout字段。后续扩展时,可以增加idle_timeout来做更精细的控制。
3.4 用 FastAPI 把命令暴露成 REST 接口
有了CommandSession,剩下的工作就是把会话包装成不同的外部接口。REST 是最传统、也最容易对接的。我用 FastAPI 写了一个简单的路由:
from fastapi import FastAPI, HTTPException from pydantic import BaseModel app = FastAPI() class RunRequest(BaseModel): args: dict = {} @app.post("/cmd/{name}/run") async def run_command(name: str, req: RunRequest): config = load_config()[name] cmd_list = build_command(config, req.args) session = CommandSession(name, cmd_list, config["timeout"]) await session.start() lines = [] async for item in session.queue: if item["type"] == "line": lines.append(item["data"]) elif item["type"] == "exit": return {"lines": lines, "code": item["code"]} raise HTTPException(status_code=500, detail="unexpected exit")这个接口的处理逻辑是:创建会话,消费输出队列,等到退出事件出现,把收集到的所有行作为 JSON 返回。为了保持接口简单,我在一次性命令模式下会消费完全部输出再返回;对于流式命令,则要改用 WebSocket。
这里有个容易被忽略的点:队列必须保证先于退出事件被消费完。因为进程可能先写最后一条输出,然后立刻退出,如果代码先读了退出事件,就来不及读那最后一行的输出了。我处理这个问题用了一个技巧:读取输出时,先处理队列里已经有的数据,再处理退出事件,不能让退出事件把队列里的残留数据冲掉。换句话说,exit事件不是最高优先级,line事件才是。
3.5 WebSocket 流式输出与前端演示
对于tail -f这类持续输出的命令,REST 显然不合适。WebSocket 天然适合流式场景。实现也很直接:客户端建立连接时传一个命令名和参数,服务端创建一个CommandSession,然后把队列里的每条line事件实时发给客户端。
from fastapi import WebSocket @app.websocket("/cmd/{name}/stream") async def stream_command(ws: WebSocket, name: str): await ws.accept() config = load_config()[name] args = await ws.receive_json() cmd_list = build_command(config, args.get("args", {})) session = CommandSession(name, cmd_list, config["timeout"]) await session.start() try: while True: item = await session.queue.get() if item["type"] == "line": await ws.send_text(item["data"]) elif item["type"] == "exit": break finally: await session.close()为了演示,我写了一个几十行 HTML 的测试页面。页面顶部是一个命令下拉框,中间是一个文本框输出区,底部是一个输入框用于向进程发送 stdin。这个页面用原生 JavaScript 的 WebSocket API,不用任何框架。实测下来,WebSocket 跑tail -f /var/log/syslog非常流畅,日志一行行出现在页面上,延迟基本可以忽略。
我还做了一个小优化:把浏览器的输入框和 WebSocket 双向绑定,用户往输入框里写内容,通过 WS 发给适配器,再由适配器写入子进程的 stdin。这样有些交互式工具也能在网页里用起来。
3.6 并发控制与背压
“任何”命令都可以暴露成接口之后,紧接着的问题是:如果同时有十个请求来跑十条ffmpeg转码命令,机器会不会直接垮掉?所以适配器需要一层简单的并发控制。
我在配置里加了一个max_concurrency字段,表示同一个命令最多有几个会话在同时运行。在启动会话前,用一个Semaphore来限制:
sessions = {} async def acquire(name): if name not in sessions: sessions[name] = asyncio.Semaphore(3) await sessions[name].acquire() async def release(name): sessions[name].release()如果并发数达到上限,新的请求应该尽快失败,而不是排队等到超时。我在接口层做了处理:尝试获取信号量,如果拿不到,直接返回 429 Too Many Requests。这样调用方可以立刻感知到压力,而不是一直傻等。
4. 常见问题与排查技巧实录
4.1 pty 模式下输出乱码或者字节不完整
用 pty 读数据时,读出来的是一段字节流,不一定正好按行切分。有时一行文本被切成两半,有时两行合并成一段。我在_read_ready里用了splitlines(),但这种方法在处理行尾没有换行的片段时会丢数据。
一个更稳的方案是维护一个缓冲区,每次读入数据后,只输出完整行,剩下的残片留在缓冲区里,等下一次读入再拼接:
self._buffer += data while b"\n" in self._buffer: line, self._buffer = self._buffer.split(b"\n", 1) self.queue.put_nowait({"type": "line", "data": line.decode("utf-8", errors="replace")})对于不按行输出的进度条类工具,上面的方案会把进度条硬生生拆成几十条“line”事件,体验很差。我最后的处理办法是:普通模式用“完整行”输出;如果配置里声明了raw: true,就把每个读到的片段都原样发出去,不做行拆分。两种模式各有适用的场景,不能一刀切。
4.2 命令明明输出了内容,但接口迟迟不返回
这是“缓冲干等”问题。ping 127.0.0.1这种工具在管道模式下,可能会把多行输出攒到一个缓冲区里,等缓冲区满了再一次性吐出来。而ping本身运行时间又长,就导致接口一直处于等待状态。
如果用 pty,大多数程序会改成行缓冲,问题是grep、sed这类管线工具依然可能做块缓冲。这种情况最简单的解法是给命令加上stdbuf -oL -eL前缀,强制标准输出和标准错误都变成行缓冲:
commands: grep_log: cmd: stdbuf args: - -oL - -eL - grep - "{{pattern}}"不过stdbuf对静态链接的程序无效,比如某些 Go 语言写的二进制文件。这时候就只能靠 pty 或者给程序设置PYTHONUNBUFFERED=1这类环境变量来强制无缓冲。
4.3 杀不掉残留进程,端口被占住
如果一个命令自己 spawn 了子进程,而我们只杀了主进程,子进程可能还活着,并且继续占用某些资源。一个典型案例是运行python3 -m http.server 8080,适配器超时杀掉了 python 主进程,但 socket 可能还处于监听状态,导致下一次启动时报端口占用。
我的处理策略很明确:第一,启动进程时设置start_new_session=True;第二,清理时用killpg杀整个进程组;第三,在配置里把端口类命令的可并发数设为 1。关于第三点,其实最好的方案是让调用方自己管好端口冲突,但作为适配层,我也提供了字段environment允许为每个会话单独注入环境变量,比如动态端口。
4.4 WebSocket 断开后进程仍在跑
前端页面如果直接关闭,WebSocket 的finally块会执行,会话被关闭。但有些客户端是异常断开的,await session.close()可能会抛异常,导致残留进程。我在实际测试中遇到过几次。
解决办法是给session.close()加上 try-except,并且把关闭逻辑放在一个独立协程里,定时检查连接状态。或者在 WebSocket 路由里,用一个disconnect保护的循环,捕获任意异常后都强制清理会话。这个细节看起来小,但在生产环境非常关键。
4.5 常见问题速查表
| 问题现象 | 可能原因 | 排查与解决 |
|---|---|---|
| 输出乱码 | 子进程输出非 UTF-8 编码 | 在配置里声明encoding: gbk,读取时用对应编码 decode |
| 命令找不到 | 子进程 PATH 未继承适配器环境 | 检查 Popen 的env,或者把命令行写成绝对路径 |
| 接口返回 500 | 参数校验失败 | 检查配置里的类型声明和必填字段是否完整 |
| 进度条在接口里变成一堆碎片 | 适配器按行切分输出 | 对这类命令声明raw: true,或者单独走 WebSocket 模式 |
| 并发峰值时 CPU 突然很高 | 大量 pty 文件描述符被浪费 | 使用asyncio.Semaphore限制并发,并设置合理的超时 |
5. 一点心得与可能的扩展方向
5.1 设计“中间表示层”的收益,远超我最初的预期
CLI-Anything 这个项目做下来,我最大的体会是:不直接拿命令和具体协议绑在一起,而是先把“命令运行”抽象成一个统一的会话层,表面上多了一层,实际上让很多事情变得简单。新增一种接口协议,比如后来我加的 MCP 模型上下文协议,只需要对同一个CommandSession写一个新的适配器,不用改动命令执行的核心逻辑。新增一个命令,也只是写一段 YAML。这个设计让整个项目保持了一种很舒服的可扩展性,维护起来也不累。
5.2 实际动手的一些建议
如果你想自己做一个类似的东西,我建议不要一上来就追求支持所有协议。先把 REST 和 WebSocket 这两个跑通,足够覆盖大多数需求了。配置中心化是必要的,但没必要一开始设计得很复杂,一个 YAML 文件就够了。等到命令数量超过二十个,再考虑按目录拆分配置、加权限控制也不迟。
调试 pty 相关问题时,建议用script命令或者socat先模拟终端环境,看看同样的命令在真实终端里是什么输出行为,再回头看适配器代码。很多问题其实是“程序在管道里和在终端里的行为差异”导致的,跟适配器本身关系不大。
5.3 后续还能怎么玩
CLI-Anything 后续的方向,我可以想到几个:一是接入聊天平台,让用户在群里发一条命令,机器人执行完再把结果贴回来;二是接 MCP,让大语言模型直接调用本地的命令行工具,相当于给模型加了一双能操作真实系统的手;三是做一个简单的 Web 管理界面,把系统里的所有命令按权限分给不同团队成员使用。每一个方向都建立在同一个核心抽象上,这也是我最兴奋的地方——一个简单的中间层,能让存量命令行生态重新焕发生机。
最后再分享一个小技巧:如果你也想做类似的工具,最重要的一件事,是把“进程生命周期管理”和“外部接口”彻底分层。我在第一版就是没分层,导致后来加 WebSocket 时,改动的范围特别大。现在分层清楚之后,加任何新功能都像插积木一样简单。这也是这个项目能一直迭代下去的根本原因。