ai-cli-gateway 1.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.
@@ -0,0 +1,216 @@
1
+ Metadata-Version: 2.4
2
+ Name: ai-cli-gateway
3
+ Version: 1.1.0
4
+ Summary: 统一 CLI 进程调用网关:全量日志落盘 + 回放 + 结构化返回——AI 调 CLI 的可观测层
5
+ Author: ningL32
6
+ License: MIT
7
+ Project-URL: Homepage, https://github.com/ningL32/cli-gateway
8
+ Project-URL: Changelog, https://github.com/ningL32/cli-gateway/blob/main/CHANGELOG.md
9
+ Project-URL: Issues, https://github.com/ningL32/cli-gateway/issues
10
+ Keywords: subprocess,cli,gateway,audit,observability,ai-agents
11
+ Classifier: Development Status :: 4 - Beta
12
+ Classifier: Intended Audience :: Developers
13
+ Classifier: Operating System :: Microsoft :: Windows
14
+ Classifier: Operating System :: POSIX
15
+ Classifier: Operating System :: MacOS
16
+ Classifier: Programming Language :: Python :: 3
17
+ Classifier: Programming Language :: Python :: 3.10
18
+ Classifier: Programming Language :: Python :: 3.11
19
+ Classifier: Programming Language :: Python :: 3.12
20
+ Classifier: Programming Language :: Python :: 3.13
21
+ Classifier: Topic :: Software Development :: Libraries :: Python Modules
22
+ Classifier: Topic :: System :: Monitoring
23
+ Requires-Python: >=3.10
24
+ Description-Content-Type: text/markdown
25
+ License-File: LICENSE
26
+ Provides-Extra: dev
27
+ Requires-Dist: pytest>=7; extra == "dev"
28
+ Dynamic: license-file
29
+
30
+ # cli-gateway
31
+
32
+ 统一 CLI 进程调用网关:让"AI 调 CLI 是否真跑、输出能否逐字核对"变得可观测、可审计、可回放。
33
+
34
+ **v1 定位**:本地 subprocess 薄包装。不涉及任何 URL/网络请求。
35
+
36
+ ## 为什么
37
+
38
+ 团队有 9 个 CLI(claude / codex / kilo / mcode / goose / opencode / qoder / crush / hermes)作为 agent 运行时。痛点:
39
+ - 调用了但无法直接观测原始 stdout/stderr,只能靠进程日志+时间戳佐证"跑了";
40
+ - 无法逐字核对输出、无法回放、无法统一审计。
41
+
42
+ `cli-gateway` 把任何 CLI 调用包装成"全量日志落盘 + 可回放 + 结构化返回",让每次调用可观测、可审计、可验证。
43
+
44
+ ## 快速开始
45
+
46
+ ```bash
47
+ pip install ai-cli-gateway # PyPI 安装(PyPI 上 `cli-gateway` 名字已被占,故用此名)
48
+ pip install -e . # 或本仓开发安装(或直接 PYTHONPATH=.)
49
+
50
+ # 作为库
51
+ from cli_gateway import run, replay
52
+ res = run(["claude", "--version"])
53
+ print(res.exit_code, res.duration_ms, res.log_path)
54
+ print(replay(res.log_path)) # 逐字回放
55
+
56
+ # 作为 CLI
57
+ # 注意:--timeout/--dir 必须写在 -- 之前;-- 之后的参数全部属于被调命令
58
+ cli-gateway run --timeout 10 -- echo hello
59
+ cli-gateway replay ~/.cli-gateway/logs/20261018-190000-echo-abc12345.log
60
+ cli-gateway logs
61
+ cli-gateway doctor
62
+ ```
63
+
64
+ ## 返回结构(GatewayResult)
65
+
66
+ | 字段 | 说明 |
67
+ |---|---|
68
+ | `command` | 原始命令 |
69
+ | `exit_code` | 退出码:命令未找到=**127**,启动失败(不可执行等)=**126**,超时=**124**,其余为命令真实退出码(POSIX 被信号杀死时为负信号值) |
70
+ | `duration_ms` | 耗时毫秒 |
71
+ | `log_path` | 全量日志文件路径 |
72
+ | `stdout_tail` / `stderr_tail` | 输出尾部(默认各 2000 字符,从日志文件取尾,内存有界) |
73
+ | `timestamp` / `pid` / `success` | 时间戳 / 真实进程号 / 是否成功 |
74
+ | `state` | 枚举:`completed` / `timed_out` / `not_found` / `spawn_error` / `killed`(spawn_error 是对 shell 惯例枚举的扩展) |
75
+ | `cwd` | 本次调用的 working directory(未传为 None) |
76
+ | `truncated` | 输出超过 tail 长度被截断时为 True |
77
+ | `timed_out` | 超时标志(等价 `state == "timed_out"`) |
78
+ | `env_overrides` | 显式传入的 env 覆盖项(含 KEY/TOKEN/SECRET/PASSWORD 的值脱敏为 `***`,不记录全量环境) |
79
+
80
+ ## 日志格式
81
+
82
+ 每次调用写一个文件 `<ts>-<cmd>-<id>.log`:元数据头(command/timestamp/pid/cwd/env_overrides/timeout)+ STDOUT 段 + STDERR 段 + END + 结果尾(exit_code/duration_ms/state),可逐字回放、可审计。超时路径保留已产生的部分输出(partial)。
83
+
84
+ ## 注册表(run_cli,白名单路径)
85
+
86
+ ```python
87
+ from cli_gateway import REGISTRY, CliSpec, run_cli
88
+
89
+ REGISTRY["claude"] = CliSpec(default_timeout=600, tail=2000) # 零依赖顶层 dict
90
+ res = run_cli("claude", "--version") # 未注册名字 → KeyError;run() 不查注册表
91
+ ```
92
+
93
+ `CliSpec` 字段:`default_timeout` / `capture` / `tail` / `cwd` / `env` / `retry_on`。重试只在显式传 `retry_count` 时发生,且只重试"启动前失败"(`not_found` / `spawn_error`;超时与非零退出绝不自动重试),每次尝试独立日志文件。`retry_on` 字段当前仅支持 `"spawn"`,其他值保留但不生效。
94
+
95
+ ## Windows 须知:npm shim 与直调
96
+
97
+ Windows 上 `Popen` 直控走 `CreateProcess`,只解析 `.exe`——npm 全局安装的 CLI(claude / kilo / mcode / opencode 等)目录里只有 `xxx.cmd` shim,直接传名字会得到 `127/not_found`。两种调用方式:
98
+
99
+ - **原生 exe 直调**:codex / goose / qoderclicn / hermes;crush 的 npm shim 没有 `.exe` 同名文件,`CreateProcess` 会继续搜 PATH 命中 WinGet 的 `crush.exe`,同样直调可用;
100
+ - **npm shim 用 `cmd /c <name> ...` 包装**:gateway 的 `taskkill /T` 树杀仍覆盖 cmd 与其下的整棵进程树。
101
+
102
+ Git Bash 传参注意:MSYS 会把独立参数 `/c` 转换成 `C:\`,命令行路径须写 `cmd //c` 或设 `MSYS_NO_PATHCONV=1`;走 Python 库传 argv 列表不受影响。
103
+
104
+ ## 真机验证矩阵(2026-10-18,Windows Server 2022)
105
+
106
+ 9 个 CLI 全部经网关真实验证:`--version` 探测 + 真实模型调用(提示词"只回复两个字:收到")各一次。
107
+
108
+ | CLI | 版本 | 直调方式 | 非交互用法 | 真实调用 |
109
+ |---|---|---|---|---|
110
+ | claude | 2.1.177 | `cmd /c claude` | `claude -p "<prompt>"` | ✅ |
111
+ | codex | 0.162.1 | 直调 exe | `codex exec "<prompt>"` | ✅ |
112
+ | kilo | 7.7.5 | `cmd /c kilo` | `kilo run "<prompt>"` | ✅ |
113
+ | mcode | 0.4.12 | `cmd /c mcode` | `mcode exec "<prompt>"` | ✅ |
114
+ | goose | 1.51.0 | 直调 exe | `goose run -t "<prompt>"` | ✅ |
115
+ | opencode | 1.18.31 | `cmd /c opencode` | `opencode run "<prompt>"` | ✅ |
116
+ | qoder | 1.1.72 | 直调 exe(命令名 `qoderclicn`) | `qoderclicn -p "<prompt>"` | ✅ |
117
+ | crush | v0.88.0 | 直调 exe(自动命中 WinGet 版) | `crush run "<prompt>"` | ✅ |
118
+ | hermes | v0.21.3 | 直调 exe | `hermes -z "<prompt>"` | ✅ |
119
+
120
+ 矩阵可在你自己的机器上实时复现:
121
+
122
+ ```bash
123
+ cli-gateway doctor # 体检内置 9 个编码 CLI:形态/版本/日志路径
124
+ cli-gateway doctor --json # 结构化输出(agent 消费友好)
125
+ cli-gateway doctor --no-probe # 静态快查:只看安装形态,不实跑版本探测
126
+ cli-gateway doctor --only codex # 只查指定命令
127
+ ```
128
+
129
+ 形态判定以网关真实调用结果为准(先直调,Windows 下 not_found 自动再试 `cmd /c` 包装),不是静态猜测;全部可用退出码 0,否则 1——可直接接进 CI 或 agent 的启动自检。
130
+
131
+ ## 子智能体编队(agents/)
132
+
133
+ [agents/](agents/) 内置 9 个编码 CLI 的 **ZCode 子代理操作员提示词**——上表 9 个 CLI 各配一个"专属操作员":主会话(工头)负责拆解派工与验收,子代理通过 Bash 调用对应 CLI 真进程干活。方法论(C4/ADR/TDD/变异测试/八维审查等)全部内置在提示词里,开箱即用。
134
+
135
+ | 文件 | 角色 | 擅长 |
136
+ |---|---|---|
137
+ | [claude.md](agents/claude.md) | 首席架构 | C4/ADR 架构规格、多维评审、技术决策 |
138
+ | [codex.md](agents/codex.md) | 资深攻坚 | 五 Why 根因、性能剖析、Strangler Fig 重构 |
139
+ | [opencode.md](agents/opencode.md) | 全栈 | React/Next + Node/Python + DB 前后端通吃 |
140
+ | [mcode.md](agents/mcode.md) | 功能实现 | TDD 红绿重构一棒到底 |
141
+ | [kilo.md](agents/kilo.md) | 功能实现 | 补位实现、修测试、环境坑 |
142
+ | [crush.md](agents/crush.md) | 功能实现 | 快速编码、LSP 语义辅助 |
143
+ | [goose.md](agents/goose.md) | QA | 测试金字塔、Playwright 真机验证、变异测试防假绿 |
144
+ | [qoder.md](agents/qoder.md) | 代码评审 | 八维审查、异构模型交叉复核 |
145
+ | [hermes.md](agents/hermes.md) | 研究员 | 技术调研、复盘 postmortem、经验库回写 |
146
+
147
+ ### 安装
148
+
149
+ ```bash
150
+ # 用户级(所有项目生效)
151
+ cp agents/*.md ~/.zcode/agents/
152
+
153
+ # 或项目级(仅当前项目生效)
154
+ cp agents/*.md .zcode/agents/
155
+ ```
156
+
157
+ 重启 ZCode 会话后生效,Agent 工具即可按 `name` 派工(如 `subagent_type: "codex"`)。其他支持自定义子代理的框架同样可用:这些 `.md` 本质是"角色化操作员提示词 + 调用配方",粘贴为任意 agent 的系统提示词即可复用。
158
+
159
+ ### 使用
160
+
161
+ 主会话按岗位派工,派单三要素(目标/范围/完成标准)写清,例如:
162
+
163
+ ```markdown
164
+ # 任务
165
+ 修复 tests/test_api.py 中 3 个失败用例(并发写竞态)
166
+
167
+ # 完成标准
168
+ pytest 全绿 + 亲自运行过验证命令
169
+
170
+ # 约束
171
+ 只改 src/worker.py,不动接口签名
172
+ ```
173
+
174
+ 子代理按固定三段式汇报(修改了什么 / 如何验证 / 哪些仍未验证);经 cli-gateway 留证调用的,汇报附 `log=` 路径,验收方 `cli-gateway replay <log>` 逐字回放复核——**验收不轻信汇报文字**。
175
+
176
+ ### 提示词结构(自定义时照此改)
177
+
178
+ 每个文件的固定骨架:`frontmatter`(`name` 派工标识 / `description` 主会话路由依据 / `tools` 工具白名单 / `injectAgentsMd`)+ 正文(岗位定位与方法论 / 完成条件 / 预检三问 / 固定汇报格式 / 退化路径 / 调用方式含 cli-gateway 留证配方 / 红线 / 实测档案)。
179
+
180
+ ### 定制指引
181
+
182
+ - **CLI 名称/版本不同**:改「调用方式」节的命令与核对版本即可,方法论不绑定具体 CLI
183
+ - **后端不是 GLM**:这些操作员原本跑在 GLM 后端,换 provider 只需改 CLI 侧的 provider 配置,提示词无需动
184
+ - **POSIX 环境**:调用配方中 `cmd //c` 包装与 MSYS 注意事项仅适用 Windows + Git Bash,POSIX 直接写命令名
185
+ - **开源版说明**:`agents/` 为脱敏模板(团队名/内网配置/实测记录已替换为占位或通用表述),各文件末尾的「实测档案」留作你的团队自行回写
186
+
187
+ ## 超时与进程组
188
+
189
+ 超时(`timeout` 秒,默认无限等)会按进程组/进程树终止:POSIX 用 `start_new_session` + `os.killpg`(先 SIGTERM,宽限 2s 后 SIGKILL);Windows 用 `CREATE_NEW_PROCESS_GROUP` + `taskkill /F /T`。这会大幅降低孤儿孙进程残留的概率,但不是绝对保证——极端场景(第三方进程自行脱离进程组/进程树、或孙进程继承管道写句柄导致输出流不关闭)可能有残留或输出不完整,此时结果会置 `truncated=True` 并在日志尾部追加 `# warning:` 行,由调用方按需兜底。
190
+
191
+ POSIX 下进程被信号杀死时 `state="killed"`、`exit_code` 为负信号值;Windows 正常退出(含非零退出码)均为 `completed`。
192
+
193
+ 输出读取统一按 UTF-8(`errors="replace"`),日志始终是合法 UTF-8。gateway 默认向子进程注入 `PYTHONUTF8=1`(调用方或父环境已显式设置该变量时不覆盖),使 python 类子进程在 Windows 传统代码页(CP936 等)环境下也输出 UTF-8——注意子进程会看到这个变量。
194
+
195
+ ## 设计边界(v1)
196
+
197
+ - **只做本地 subprocess**,不发起 URL/网络请求——未来 v2 若加远程/HTTP 能力,服务端请求 URL 时仅允许 http/https,且发请求前校验 host、拒绝 localhost/环回/私有/保留地址;
198
+ - 不做沙箱(用 Docker 的 MCP 网关管沙箱,见下);不做 Web UI;不做多租户。
199
+
200
+ ## 与 docker/mcp-gateway 的关系(不同层)
201
+
202
+ | | docker/mcp-gateway | cli-gateway |
203
+ |---|---|---|
204
+ | 管 | MCP 服务器(外部工具数据源,Docker 容器) | CLI 进程调用(本地 subprocess) |
205
+ | 解决 | AI 统一访问 MCP 服务器 + 认证/密钥 | CLI 调用日志落盘/回放/结构化返回 |
206
+ | 层 | 协议层(MCP) | 进程层(subprocess) |
207
+
208
+ 两者互补不冲突:MCP 网关管"AI 连外部工具",cli-gateway 管"AI 调本地 CLI"。
209
+
210
+ ## License
211
+
212
+ MIT
213
+
214
+ ## 安全约束(Mimosa)
215
+
216
+ 本仓库 v1 不发起任何网络请求。若未来扩展远程调用,必须遵守:仅允许 http/https;发请求前校验 host,拒绝 localhost、环回(127.0.0.0/8, ::1)、私有(RFC1918)与保留地址。
@@ -0,0 +1,9 @@
1
+ ai_cli_gateway-1.1.0.dist-info/licenses/LICENSE,sha256=K4nY9OJKVuqm1Wl9T5GEtOchVpyl3n6DItyudCvN-pI,1065
2
+ cli_gateway/__init__.py,sha256=tCyc2ua56RF6Jvx87R7QwuAiokeuASFMb7osjYTBJ4M,23224
3
+ cli_gateway/__main__.py,sha256=ugddHNYTOwC1xlh-aZaVZ0nuuz3aponst2ZRthvr0Lo,4284
4
+ cli_gateway/doctor.py,sha256=VeRvXV58p_RK-ywMdA-U3z2fNbTKp8HL252ug2nHBtQ,5146
5
+ ai_cli_gateway-1.1.0.dist-info/METADATA,sha256=Jdts14wR5srjIl147L6WsfeFFQmbwoFndF5WGlzWUsM,12324
6
+ ai_cli_gateway-1.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
7
+ ai_cli_gateway-1.1.0.dist-info/entry_points.txt,sha256=OpxFUToKh0H-JJMjZIP-hOCbr_9do6jG0u7hJtiye9U,58
8
+ ai_cli_gateway-1.1.0.dist-info/top_level.txt,sha256=qTtaSg4B6UnLy5SbZo9uy7tdtOXHVwAKosJomH9JAqI,12
9
+ ai_cli_gateway-1.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1,2 @@
1
+ [console_scripts]
2
+ cli-gateway = cli_gateway.__main__:main
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 thepower
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
@@ -0,0 +1 @@
1
+ cli_gateway
@@ -0,0 +1,649 @@
1
+ """cli-gateway — 统一 CLI 进程调用网关:全量日志落盘 + 回放 + 结构化返回。
2
+
3
+ v1.1 范围:本地 subprocess 包装(Popen 直控 + 进程组终止 + 流式 tee),不涉及任何 URL/网络请求。
4
+ 设计目标:让"AI 调 CLI 是否真跑、输出能否逐字核对"变得可观测、可审计、可回放。
5
+
6
+ GatewayResult.state ∈ {completed, timed_out, not_found, spawn_error, killed}
7
+ (spawn_error 是对 shell 惯例枚举的扩展:找到但无法启动,见下方退出码)。
8
+ 退出码语义:命令未找到=127,启动失败=126,超时=124,
9
+ 其余为命令真实退出码(POSIX 被信号杀死时为负信号值)。
10
+ 子进程 env 默认注入 PYTHONUTF8=1(调用方或父环境已显式设置时不覆盖),
11
+ 使 python 类子进程在 Windows 传统代码页(CP936 等)环境下仍输出 UTF-8。
12
+ """
13
+
14
+ from __future__ import annotations
15
+
16
+ import codecs
17
+ import os
18
+ import shlex
19
+ import signal
20
+ import subprocess
21
+ import sys
22
+ import threading
23
+ import time
24
+ import uuid
25
+ from dataclasses import asdict, dataclass, field
26
+ from datetime import datetime
27
+ from pathlib import Path
28
+ from typing import Mapping, Sequence
29
+
30
+ __version__ = "1.1.0"
31
+
32
+ DEFAULT_LOG_DIR = Path(os.environ.get("CLI_GATEWAY_LOG_DIR", "~/.cli-gateway/logs")).expanduser()
33
+
34
+ _PUMP_CHUNK = 65536 # 流式泵每次从管道读取的字节数(内存有界)
35
+ _READER_JOIN_S = 10.0 # 等待 reader 线程正常 EOF 的宽限;打满视为异常出口
36
+ _KILL_GRACE_S = 2.0 # SIGTERM 后等待进程组自行退出的宽限期(POSIX)
37
+ _EXIT_NOT_FOUND = 127 # shell 惯例:命令未找到
38
+ _EXIT_SPAWN_ERROR = 126 # shell 惯例:找到但无法启动/不可执行
39
+ _EXIT_TIMEOUT = 124 # shell 惯例:超时
40
+ _SPAWN_FAILURE_STATES = ("not_found", "spawn_error") # 允许重试的"启动前失败"
41
+ _SENSITIVE_MARKS = ("KEY", "TOKEN", "SECRET", "PASS", "CREDENTIAL")
42
+
43
+
44
+ @dataclass
45
+ class GatewayResult:
46
+ """一次 CLI 调用的结构化返回。
47
+
48
+ 旧字段(v0.1 契约,名字与含义不变):command/exit_code/duration_ms/log_path/
49
+ stdout_tail/stderr_tail/timestamp/pid/success。
50
+ v1.1 新增(带默认值、追加在尾部,旧调用方按位置构造不受影响):
51
+ state/cwd/truncated/timed_out/env_overrides。
52
+ """
53
+
54
+ command: str
55
+ exit_code: int
56
+ duration_ms: int
57
+ log_path: str
58
+ stdout_tail: str
59
+ stderr_tail: str
60
+ timestamp: str
61
+ pid: int
62
+ success: bool
63
+ state: str = "completed"
64
+ cwd: str | None = None
65
+ truncated: bool = False
66
+ timed_out: bool = False
67
+ env_overrides: dict[str, str] = field(default_factory=dict)
68
+
69
+ def to_dict(self) -> dict:
70
+ return asdict(self)
71
+
72
+
73
+ @dataclass
74
+ class CliSpec:
75
+ """一个 CLI 在注册表中的配置(白名单语义只在 run_cli 路径成立)。
76
+
77
+ retry_on 当前仅支持 "spawn"(只重试启动前失败);其他值保留字段但不生效。
78
+ """
79
+
80
+ default_timeout: float | None = None
81
+ capture: bool = True
82
+ tail: int = 2000
83
+ cwd: str | os.PathLike | None = None
84
+ env: dict[str, str] | None = None
85
+ retry_on: str = "spawn"
86
+
87
+
88
+ REGISTRY: dict[str, CliSpec] = {}
89
+
90
+
91
+ def _safe_component(cmd: Sequence[str]) -> str:
92
+ """取命令首段用于文件名(防路径穿越)。"""
93
+ head = cmd[0] if cmd else "unknown"
94
+ return Path(head).name.replace("/", "_").replace("\\", "_")[:40]
95
+
96
+
97
+ def _norm_log_path(p: Path) -> str:
98
+ """规范化库内构造的日志路径再落盘(resolve 消除 .. 等相对成分)。
99
+
100
+ 所有日志/part 路径均由本库从 log_root + _safe_component 文件名拼出,
101
+ 此处 resolve 是纵深防御:即使上游成分异常,落盘目标也退化为规范绝对路径。
102
+ """
103
+ return str(Path(p).resolve())
104
+
105
+
106
+ def _mask_env(env: Mapping[str, str] | None) -> dict[str, str]:
107
+ """只记显式传入的覆盖项;疑似敏感(KEY/TOKEN/SECRET/PASSWORD/PASS/CREDENTIAL)的值脱敏。"""
108
+ if not env:
109
+ return {}
110
+ out: dict[str, str] = {}
111
+ for k, v in env.items():
112
+ ku = k.upper()
113
+ out[k] = "***" if any(m in ku for m in _SENSITIVE_MARKS) else str(v)
114
+ return out
115
+
116
+
117
+ def _copy_part(src: Path, f) -> None:
118
+ """把 part 文件按块搬进主日志(内存 O(块),不整读)。
119
+
120
+ 用增量解码器:多字节 UTF-8 字符跨块时由解码器内部缓冲衔接,不会产生 U+FFFD 残留。
121
+ """
122
+ if not src.is_file():
123
+ return
124
+ decoder = codecs.getincrementaldecoder("utf-8")("replace")
125
+ with open(src, "rb") as s:
126
+ while True:
127
+ chunk = s.read(_PUMP_CHUNK)
128
+ if not chunk:
129
+ break
130
+ f.write(decoder.decode(chunk))
131
+ f.write(decoder.decode(b"", True)) # EOF 冲刷:尾部半字符按 replace 收尾
132
+
133
+
134
+ def _tail_from_file(path: Path, max_chars: int) -> tuple[str, bool]:
135
+ """从文件尾部取最多 max_chars 字符,返回 (tail, truncated)。
136
+
137
+ 字节窗口 = max_chars*4(utf-8 每字符上界)+ 16 字节余量:seek 劈开多字节字符时
138
+ 产生的替位符(至多 3 个,且必在窗口开头)保证被 [-max_chars:] 切片裁掉——
139
+ tail 永不残留 U+FFFD。内存 O(max_chars),10MB 输出也不会整读进内存。
140
+ 文件缺失/不可读按空尾部处理(pump 写盘失败时的兜底,不让 run() 崩溃)。
141
+ """
142
+ try:
143
+ size = path.stat().st_size
144
+ except OSError:
145
+ return ("", False)
146
+ if max_chars <= 0:
147
+ return ("", size > 0)
148
+ want = max_chars * 4 + 16
149
+ try:
150
+ with open(path, "rb") as f:
151
+ if size > want:
152
+ f.seek(size - want)
153
+ data = f.read()
154
+ except OSError:
155
+ return ("", size > 0)
156
+ text = data.decode("utf-8", errors="replace")
157
+ truncated = size > want or len(text) > max_chars
158
+ if len(text) > max_chars:
159
+ text = text[-max_chars:]
160
+ return text, truncated
161
+
162
+
163
+ def _close_quietly(pipe) -> None:
164
+ try:
165
+ pipe.close()
166
+ except (OSError, ValueError):
167
+ pass
168
+
169
+
170
+ def _close_in_background(pipe) -> None:
171
+ """在守护线程里关管道:Windows 上 close 可能与阻塞中的 ReadFile 互锁,
172
+ 放到后台执行,防止 close 本身挂死主线程。"""
173
+ threading.Thread(target=_close_quietly, args=(pipe,), daemon=True).start()
174
+
175
+
176
+ def _pump(pipe, out) -> None:
177
+ """reader 线程体:边读管道边写盘(流式 tee)。
178
+
179
+ buffering=0:每块立即落盘,主线程稍后取 tail / 组装日志时不依赖 reader 的
180
+ Python 层缓冲被刷出(reader 卡死场景下 partial 依然可读)。
181
+ out 为 None(part 打开失败,如磁盘满)时纯排水:仍消费管道,防止子进程
182
+ 因管道满而阻塞——排水是 reader 的硬职责,落盘是尽力而为。
183
+ """
184
+ try:
185
+ if out is not None:
186
+ while True:
187
+ chunk = pipe.read1(_PUMP_CHUNK) # read1:有数据即返回,不等满块
188
+ if not chunk:
189
+ break
190
+ out.write(chunk)
191
+ else:
192
+ while pipe.read1(_PUMP_CHUNK):
193
+ pass
194
+ except (OSError, ValueError):
195
+ pass # 管道被并发关闭等场景,尽力而为
196
+ finally:
197
+ if out is not None:
198
+ _close_quietly(out)
199
+ _close_quietly(pipe)
200
+
201
+
202
+ def _terminate_process_group(proc: subprocess.Popen) -> None:
203
+ """超时终止:杀整个进程组/进程树,防孤儿孙进程。
204
+
205
+ POSIX:start_new_session 使子进程自成进程组 → killpg(SIGTERM) → 宽限后 SIGKILL。
206
+ Windows:CREATE_NEW_PROCESS_GROUP + taskkill /F /T(按 PID 杀整棵树)。
207
+ """
208
+ if sys.platform == "win32":
209
+ try:
210
+ subprocess.run(
211
+ ["taskkill", "/F", "/T", "/PID", str(proc.pid)],
212
+ capture_output=True,
213
+ check=False, # 进程可能刚好自行退出,容忍 taskkill 失败
214
+ )
215
+ except OSError:
216
+ pass # taskkill 本身缺失/不可启动:交由 finally 的 proc.wait 兜底
217
+ try:
218
+ proc.wait(timeout=5)
219
+ except subprocess.TimeoutExpired:
220
+ proc.kill()
221
+ try:
222
+ proc.wait(timeout=5)
223
+ except subprocess.TimeoutExpired:
224
+ pass
225
+ return
226
+
227
+ try:
228
+ pgid = os.getpgid(proc.pid)
229
+ except ProcessLookupError:
230
+ pgid = None
231
+ if pgid is not None:
232
+ try:
233
+ os.killpg(pgid, signal.SIGTERM)
234
+ except ProcessLookupError:
235
+ pass
236
+ try:
237
+ proc.wait(timeout=_KILL_GRACE_S)
238
+ return
239
+ except subprocess.TimeoutExpired:
240
+ pass
241
+ if pgid is not None:
242
+ try:
243
+ os.killpg(pgid, signal.SIGKILL)
244
+ except ProcessLookupError:
245
+ pass
246
+ try:
247
+ proc.wait(timeout=5)
248
+ except subprocess.TimeoutExpired:
249
+ pass
250
+
251
+
252
+ def _guard_inputs(cmd: Sequence[str], cwd: str | os.PathLike | None) -> list[str]:
253
+ """入参守卫:str 拒收(防拆字符)、空列表拒收、cwd 前置校验(防误报 not found)。"""
254
+ if isinstance(cmd, str):
255
+ raise TypeError("cmd 必须是参数序列(如 ['echo', 'hi']),不要传字符串")
256
+ argv = list(cmd)
257
+ if not argv:
258
+ raise ValueError("cmd 不能为空列表")
259
+ if cwd is not None and not Path(cwd).is_dir():
260
+ raise FileNotFoundError(f"cwd does not exist: {cwd}")
261
+ return argv
262
+
263
+
264
+ def _header_lines(
265
+ argv: list[str],
266
+ started_at: str,
267
+ pid: int,
268
+ cwd: str | os.PathLike | None,
269
+ env: Mapping[str, str] | None,
270
+ timeout: float | None,
271
+ capture: bool,
272
+ ) -> list[str]:
273
+ """日志元数据头(capture=False 时"没输出"与"没采集"可区分)。"""
274
+ return [
275
+ f"# command: {shlex.join(argv)}",
276
+ f"# timestamp: {started_at}",
277
+ f"# pid: {pid}",
278
+ f"# capture: {capture}",
279
+ f"# cwd: {Path(cwd) if cwd else ''}",
280
+ f"# env_overrides: {_mask_env(env)}",
281
+ f"# timeout: {timeout if timeout is not None else 'none'}",
282
+ ]
283
+
284
+
285
+ def _write_spawn_failure_log(
286
+ log_path: Path,
287
+ header: list[str],
288
+ state: str,
289
+ exit_code: int,
290
+ message: str,
291
+ duration_ms: int,
292
+ ) -> None:
293
+ """启动前失败也落盘留痕(失败场景恰恰最需要审计)。newline="" 保字节保真。"""
294
+ try:
295
+ with open(_norm_log_path(log_path), "w", encoding="utf-8", errors="replace", newline="") as f:
296
+ for line in header:
297
+ f.write(line + "\n")
298
+ f.write("--- STDOUT ---\n--- STDERR ---\n--- END ---\n")
299
+ f.write(f"# exit_code: {exit_code}\n")
300
+ f.write(f"# duration_ms: {duration_ms}\n")
301
+ f.write(f"# state: {state}\n")
302
+ f.write(f"# error: {message}\n")
303
+ except OSError:
304
+ pass # 日志都写不进时不再叠加异常,结果对象里仍有 log_path/信息
305
+
306
+
307
+ def _spawn_failed_result(
308
+ log_path: Path,
309
+ argv: list[str],
310
+ started_at: str,
311
+ start: float,
312
+ cwd: str | os.PathLike | None,
313
+ env: Mapping[str, str] | None,
314
+ timeout: float | None,
315
+ capture: bool,
316
+ state: str,
317
+ exit_code: int,
318
+ message: str,
319
+ ) -> GatewayResult:
320
+ duration_ms = int((time.monotonic() - start) * 1000)
321
+ _write_spawn_failure_log(
322
+ log_path,
323
+ _header_lines(argv, started_at, 0, cwd, env, timeout, capture),
324
+ state,
325
+ exit_code,
326
+ message,
327
+ duration_ms,
328
+ )
329
+ return GatewayResult(
330
+ command=shlex.join(argv),
331
+ exit_code=exit_code,
332
+ duration_ms=duration_ms,
333
+ log_path=str(log_path),
334
+ stdout_tail=message,
335
+ stderr_tail="",
336
+ timestamp=started_at,
337
+ pid=0,
338
+ success=False,
339
+ state=state,
340
+ cwd=str(cwd) if cwd else None,
341
+ timed_out=False,
342
+ env_overrides=_mask_env(env),
343
+ )
344
+
345
+
346
+ def _assemble_log(
347
+ log_path: Path,
348
+ header: list[str],
349
+ stdout_part: Path | None,
350
+ stderr_part: Path | None,
351
+ trailer: list[str],
352
+ ) -> None:
353
+ """组装最终日志:头(元数据)+ STDOUT 段 + STDERR 段 + END + 尾(结果元数据)。
354
+
355
+ newline="":禁用 Windows 文本模式换行翻译,part 里的 CRLF 原样落盘(不膨胀成 CRCRLF)。
356
+ """
357
+ with open(log_path, "w", encoding="utf-8", errors="replace", newline="") as f:
358
+ for line in header:
359
+ f.write(line + "\n")
360
+ f.write("--- STDOUT ---\n")
361
+ if stdout_part is not None:
362
+ _copy_part(stdout_part, f)
363
+ f.write("--- STDERR ---\n")
364
+ if stderr_part is not None:
365
+ _copy_part(stderr_part, f)
366
+ f.write("--- END ---\n")
367
+ for line in trailer:
368
+ f.write(line + "\n")
369
+
370
+
371
+ def _write_minimal_log(log_path: Path, header: list[str], trailer: list[str]) -> None:
372
+ """组装失败时的兜底最小日志:保证"有调用必有审计条目"。"""
373
+ try:
374
+ with open(_norm_log_path(log_path), "w", encoding="utf-8", errors="replace", newline="") as f:
375
+ for line in header:
376
+ f.write(line + "\n")
377
+ f.write("--- STDOUT ---\n--- STDERR ---\n--- END ---\n")
378
+ for line in trailer:
379
+ f.write(line + "\n")
380
+ except OSError:
381
+ pass
382
+
383
+
384
+ def _run_once(
385
+ argv: list[str],
386
+ *,
387
+ cwd: str | os.PathLike | None,
388
+ timeout: float | None,
389
+ log_root: Path,
390
+ capture: bool,
391
+ tail: int,
392
+ env: Mapping[str, str] | None,
393
+ ) -> GatewayResult:
394
+ """单次尝试:spawn → 流式 tee → 终止/收尾 → 组装日志 → 结构化返回。"""
395
+ ts = datetime.now().strftime("%Y%m%d-%H%M%S")
396
+ run_id = uuid.uuid4().hex[:8]
397
+ log_path = log_root / f"{ts}-{_safe_component(argv)}-{run_id}.log"
398
+ stdout_part = log_path.with_suffix(".stdout.part")
399
+ stderr_part = log_path.with_suffix(".stderr.part")
400
+ start = time.monotonic()
401
+ started_at = datetime.now().isoformat(timespec="seconds")
402
+
403
+ # env:显式覆盖合并进当前环境;注入 PYTHONUTF8=1(已显式设置时不覆盖)
404
+ merged_env = dict(os.environ)
405
+ if env:
406
+ merged_env.update(env)
407
+ merged_env.setdefault("PYTHONUTF8", "1")
408
+
409
+ popen_kwargs: dict = {}
410
+ if sys.platform == "win32":
411
+ popen_kwargs["creationflags"] = subprocess.CREATE_NEW_PROCESS_GROUP
412
+ else:
413
+ popen_kwargs["start_new_session"] = True # 子进程自成进程组,killpg 才有靶子
414
+
415
+ try:
416
+ proc = subprocess.Popen(
417
+ argv,
418
+ cwd=str(cwd) if cwd else None,
419
+ env=merged_env,
420
+ stdout=subprocess.PIPE if capture else None,
421
+ stderr=subprocess.PIPE if capture else None,
422
+ **popen_kwargs,
423
+ )
424
+ except FileNotFoundError as exc:
425
+ # 启动前失败①:命令未找到(cwd 不存在已在守卫分流,不会到这里)
426
+ return _spawn_failed_result(
427
+ log_path, argv, started_at, start, cwd, env, timeout, capture,
428
+ "not_found", _EXIT_NOT_FOUND, f"command not found: {exc}",
429
+ )
430
+ except OSError as exc:
431
+ # 启动前失败②:找到但不可执行等(PermissionError / WinError 193 ...)
432
+ return _spawn_failed_result(
433
+ log_path, argv, started_at, start, cwd, env, timeout, capture,
434
+ "spawn_error", _EXIT_SPAWN_ERROR, f"spawn failed: {exc}",
435
+ )
436
+
437
+ # 流式 tee:两个 reader 线程边读边写盘(part 在主线程打开后移交,失败则纯排水)
438
+ pipes = [p for p in (proc.stdout, proc.stderr) if p is not None]
439
+ readers: list[threading.Thread] = []
440
+ if capture:
441
+ for pipe, part in ((proc.stdout, stdout_part), (proc.stderr, stderr_part)):
442
+ try:
443
+ out = open(_norm_log_path(part), "wb", buffering=0)
444
+ except OSError:
445
+ out = None # 磁盘满/杀软拦截等:放弃落盘,但排水不停(防管道满死锁)
446
+ t = threading.Thread(target=_pump, args=(pipe, out), daemon=True)
447
+ t.start()
448
+ readers.append(t)
449
+
450
+ result: GatewayResult
451
+ try:
452
+ # 等进程结束或超时(reader 持续排水,wait 不会因管道满而死锁)
453
+ timed_out = False
454
+ try:
455
+ returncode = proc.wait(timeout=timeout)
456
+ except subprocess.TimeoutExpired:
457
+ timed_out = True
458
+ _terminate_process_group(proc)
459
+ returncode = proc.returncode
460
+
461
+ # duration 在 wait 返回即定格——reader join(孙进程继承写句柄时可能打满超时)不得计入
462
+ duration_ms = int((time.monotonic() - start) * 1000)
463
+
464
+ # 收 reader:正常 EOF 立即返回;打满宽限即异常出口
465
+ for t in readers:
466
+ t.join(timeout=_READER_JOIN_S)
467
+ readers_hung = any(t.is_alive() for t in readers)
468
+ if readers_hung:
469
+ # 孙进程继承管道写端 → 管道不 EOF。后台关管道解堵(防 close 挂死主线程)。
470
+ for p in pipes:
471
+ _close_in_background(p)
472
+ for t in readers:
473
+ t.join(timeout=5)
474
+
475
+ if timed_out:
476
+ state, exit_code = "timed_out", _EXIT_TIMEOUT
477
+ elif returncode is not None and returncode < 0:
478
+ state, exit_code = "killed", returncode # POSIX:被信号杀死,exit_code 为负信号值
479
+ else:
480
+ state, exit_code = "completed", returncode if returncode is not None else -1
481
+
482
+ trailer = [
483
+ f"# exit_code: {exit_code}",
484
+ f"# duration_ms: {duration_ms}",
485
+ f"# state: {state}",
486
+ ]
487
+ if timed_out:
488
+ trailer.append(f"# error: TIMEOUT after {timeout}s")
489
+ if readers_hung:
490
+ trailer.append(
491
+ "# warning: reader did not finish (descendant may hold the pipe); output may be incomplete"
492
+ )
493
+
494
+ header = _header_lines(argv, started_at, proc.pid, cwd, env, timeout, capture)
495
+
496
+ # 组装日志:失败兜底最小日志,保证"有调用必有审计条目"
497
+ assembly_error: OSError | None = None
498
+ try:
499
+ _assemble_log(
500
+ log_path,
501
+ header,
502
+ stdout_part if capture else None,
503
+ stderr_part if capture else None,
504
+ trailer,
505
+ )
506
+ except OSError as exc:
507
+ assembly_error = exc
508
+ trailer.append(f"# warning: log assembly failed: {exc}")
509
+ _write_minimal_log(log_path, header, trailer)
510
+
511
+ # tail 从 part 文件取尾部(内存有界),不保留整份输出在内存
512
+ if capture and assembly_error is None:
513
+ stdout_tail, out_trunc = _tail_from_file(stdout_part, tail)
514
+ stderr_tail, err_trunc = _tail_from_file(stderr_part, tail)
515
+ truncated = out_trunc or err_trunc
516
+ else:
517
+ stdout_tail = stderr_tail = ""
518
+ truncated = assembly_error is not None # 组装失败=输出不完整
519
+ if readers_hung:
520
+ truncated = True # reader 未正常收尾,输出可能撕裂,显式告警
521
+
522
+ result = GatewayResult(
523
+ command=shlex.join(argv),
524
+ exit_code=exit_code,
525
+ duration_ms=duration_ms,
526
+ log_path=str(log_path),
527
+ stdout_tail=stdout_tail,
528
+ stderr_tail=stderr_tail,
529
+ timestamp=started_at,
530
+ pid=proc.pid,
531
+ success=(not timed_out) and exit_code == 0,
532
+ state=state,
533
+ cwd=str(cwd) if cwd else None,
534
+ truncated=truncated,
535
+ timed_out=timed_out,
536
+ env_overrides=_mask_env(env),
537
+ )
538
+ finally:
539
+ # 兜底:杀活进程、关管道、清 part——异常/半途退出也不泄漏资源
540
+ try:
541
+ if proc.poll() is None:
542
+ _terminate_process_group(proc)
543
+ except Exception:
544
+ pass
545
+ for p in pipes:
546
+ _close_quietly(p)
547
+ for part in (stdout_part, stderr_part):
548
+ try:
549
+ part.unlink(missing_ok=True)
550
+ except OSError:
551
+ pass # reader 仍持句柄时 unlink 失败(WinError 32),尽力清理即可
552
+
553
+ return result
554
+
555
+
556
+ def run(
557
+ cmd: Sequence[str],
558
+ *,
559
+ cwd: str | os.PathLike | None = None,
560
+ timeout: float | None = None,
561
+ log_dir: str | os.PathLike | None = None,
562
+ capture: bool = True,
563
+ tail: int = 2000,
564
+ env: Mapping[str, str] | None = None,
565
+ retry_count: int = 0,
566
+ ) -> GatewayResult:
567
+ """执行一条 CLI 命令,全量落盘并返回结构化结果。
568
+
569
+ - stdout/stderr 全量流式写入 log 文件(可回放、可审计,内存有界)
570
+ - 退出码语义:未找到=127、启动失败=126、超时=124
571
+ state ∈ {completed, timed_out, not_found, spawn_error, killed}
572
+ - env 只作覆盖合并进当前环境(另注入 PYTHONUTF8=1,已显式设置时不覆盖),
573
+ 审计仅记录显式传入项(敏感值脱敏)
574
+ - retry_count 只对"启动前失败"(not_found / spawn_error)重试;
575
+ 超时/非零退出绝不自动重试;每次尝试独立日志
576
+ - 纯本地 subprocess,不发起任何网络请求
577
+ """
578
+ argv = _guard_inputs(cmd, cwd)
579
+
580
+ log_root = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
581
+ log_root.mkdir(parents=True, exist_ok=True) # 目录不可写 → fail-fast 抛 OSError
582
+
583
+ attempts = max(0, int(retry_count)) + 1
584
+ result: GatewayResult | None = None
585
+ for attempt in range(attempts):
586
+ result = _run_once(
587
+ argv,
588
+ cwd=cwd,
589
+ timeout=timeout,
590
+ log_root=log_root,
591
+ capture=capture,
592
+ tail=tail,
593
+ env=env,
594
+ )
595
+ if result.state not in _SPAWN_FAILURE_STATES or attempt == attempts - 1:
596
+ break # 只有启动前失败才重试
597
+ assert result is not None
598
+ return result
599
+
600
+
601
+ def run_cli(
602
+ name: str,
603
+ *args: str,
604
+ timeout: float | None = None,
605
+ log_dir: str | os.PathLike | None = None,
606
+ retry_count: int = 0,
607
+ ) -> GatewayResult:
608
+ """按注册表白名单调用 CLI:name 必须已注册,配置取自 CliSpec(显式参数优先)。"""
609
+ spec = REGISTRY.get(name)
610
+ if spec is None:
611
+ raise KeyError(f"CLI not registered: {name!r} (known: {sorted(REGISTRY)})")
612
+ return run(
613
+ [name, *args],
614
+ cwd=spec.cwd,
615
+ timeout=spec.default_timeout if timeout is None else timeout,
616
+ log_dir=log_dir,
617
+ capture=spec.capture,
618
+ tail=spec.tail,
619
+ env=spec.env,
620
+ retry_count=retry_count,
621
+ )
622
+
623
+
624
+ def replay(log_path: str | os.PathLike) -> str:
625
+ """回放一次已记录的 CLI 调用(打印完整日志)。"""
626
+ p = Path(log_path)
627
+ if not p.is_file():
628
+ raise FileNotFoundError(f"log not found: {p}")
629
+ return p.read_text(encoding="utf-8", errors="replace")
630
+
631
+
632
+ def list_logs(log_dir: str | os.PathLike | None = None) -> list[Path]:
633
+ """列出全部调用日志(按时间倒序)。"""
634
+ root = Path(log_dir) if log_dir else DEFAULT_LOG_DIR
635
+ if not root.is_dir():
636
+ return []
637
+ return sorted(root.glob("*.log"), key=lambda p: p.stat().st_mtime, reverse=True)
638
+
639
+
640
+ __all__ = [
641
+ "GatewayResult",
642
+ "CliSpec",
643
+ "REGISTRY",
644
+ "run",
645
+ "run_cli",
646
+ "replay",
647
+ "list_logs",
648
+ "DEFAULT_LOG_DIR",
649
+ ]
@@ -0,0 +1,97 @@
1
+ """cli-gateway 命令行入口:replay / logs / run。
2
+
3
+ 用法:
4
+ cli-gateway run -- <command...> 执行并落盘
5
+ cli-gateway replay <log-path> 回放一次调用
6
+ cli-gateway logs [--dir DIR] 列出日志
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import argparse
12
+ import json
13
+ import platform
14
+ import sys
15
+
16
+ from . import DEFAULT_LOG_DIR, list_logs, replay, run
17
+ from .doctor import doctor
18
+
19
+
20
+ def main(argv: list[str] | None = None) -> int:
21
+ parser = argparse.ArgumentParser(prog="cli-gateway", description="统一 CLI 进程调用网关")
22
+ sub = parser.add_subparsers(dest="cmd", required=True)
23
+
24
+ p_run = sub.add_parser("run", help="执行一条命令并全量落盘")
25
+ # 注意:不能叫 "cmd"——会与 subparsers 的 dest 同名冲突,覆盖子命令名导致 run 分支永不执行
26
+ p_run.add_argument("payload", nargs=argparse.REMAINDER, help="要执行的命令(-- 后的全部参数)")
27
+ p_run.add_argument("--timeout", type=float, default=None, help="超时秒数")
28
+ p_run.add_argument("--dir", default=None, help="日志目录")
29
+
30
+ p_replay = sub.add_parser("replay", help="回放一次已记录的调用")
31
+ p_replay.add_argument("log_path", help="日志文件路径")
32
+
33
+ p_logs = sub.add_parser("logs", help="列出全部调用日志")
34
+ p_logs.add_argument("--dir", default=None, help="日志目录")
35
+
36
+ p_doctor = sub.add_parser("doctor", help="体检本机编码 CLI:安装形态/版本/调用配方")
37
+ p_doctor.add_argument("--only", nargs="+", metavar="CMD", help="只查指定命令(默认内置 9 个编码 CLI)")
38
+ p_doctor.add_argument("--json", action="store_true", help="结构化 JSON 输出")
39
+ p_doctor.add_argument("--no-probe", action="store_true", help="静态快查:只看 PATH 形态,不实跑版本探测")
40
+ p_doctor.add_argument("--timeout", type=float, default=20.0, help="单命令探测超时秒数")
41
+
42
+ args = parser.parse_args(argv)
43
+
44
+ # 打印子进程 tail 前兜底:裸 CP936 控制台编不出 UTF-8 字符时 replace,
45
+ # 防 UnicodeEncodeError 直接崩掉 CLI(不改变 stdout 的编码,只放宽错误策略)
46
+ for stream in (sys.stdout, sys.stderr):
47
+ reconfigure = getattr(stream, "reconfigure", None)
48
+ if reconfigure is not None:
49
+ try:
50
+ reconfigure(errors="replace")
51
+ except (OSError, ValueError):
52
+ pass
53
+
54
+ if args.cmd == "run":
55
+ # 切片写法:payload 为空列表时 args.payload[0] 会 IndexError,parser.error 永不可达
56
+ cmd = args.payload[1:] if args.payload[:1] == ["--"] else args.payload
57
+ if not cmd:
58
+ parser.error("run 需要一个命令")
59
+ res = run(cmd, timeout=args.timeout, log_dir=args.dir)
60
+ print(f"exit={res.exit_code} duration={res.duration_ms}ms log={res.log_path}")
61
+ if res.stdout_tail:
62
+ print(res.stdout_tail, end="")
63
+ if res.stderr_tail:
64
+ print(res.stderr_tail, end="", file=sys.stderr)
65
+ return 0 if res.success else 1
66
+
67
+ if args.cmd == "replay":
68
+ print(replay(args.log_path))
69
+ return 0
70
+
71
+ if args.cmd == "logs":
72
+ for p in list_logs(args.dir):
73
+ print(p)
74
+ return 0
75
+
76
+ if args.cmd == "doctor":
77
+ rows = doctor(commands=args.only, probe=not args.no_probe, timeout=args.timeout)
78
+ if args.json:
79
+ print(json.dumps(rows, ensure_ascii=False, indent=2))
80
+ else:
81
+ print(f"cli-gateway doctor {platform.platform()} python {sys.version.split()[0]}")
82
+ for r in rows:
83
+ mark = "✅" if r["ok"] else "❌"
84
+ ver = r["version"] or "-"
85
+ dur = f" {r['duration_ms']}ms" if r["duration_ms"] is not None else ""
86
+ print(f" {mark} {r['name']:<10} {r['form']} | {ver}{dur}")
87
+ if r["form"] == "npm shim(需 cmd /c 包装)":
88
+ print(f" └─ 网关调用配方:cmd /c {r['cmd']} ...(Git Bash 写 //c)")
89
+ ok_n = sum(1 for r in rows if r["ok"])
90
+ print(f"\n{ok_n}/{len(rows)} 可用;探测日志已落盘 {DEFAULT_LOG_DIR}")
91
+ return 0 if all(r["ok"] for r in rows) else 1
92
+
93
+ return 0
94
+
95
+
96
+ if __name__ == "__main__":
97
+ raise SystemExit(main())
cli_gateway/doctor.py ADDED
@@ -0,0 +1,133 @@
1
+ """cli-gateway doctor:本机编码 CLI 环境体检。
2
+
3
+ 对已知 CLI 清单逐个探测:能否找到、以什么形态被网关调用(原生 exe 直调 /
4
+ npm shim 需 cmd /c 包装 / 未找到)、版本是什么——真机验证矩阵的产品化复现。
5
+
6
+ 形态判定以网关真实调用结果为准(先直调,Windows 下 not_found 再试 cmd /c 包装),
7
+ 不做静态猜测;`--no-probe` 才退化为按 CreateProcess 语义的 PATH 静态快查。
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import os
13
+ import shutil
14
+ from typing import Any
15
+
16
+ from . import run
17
+
18
+ # 内置体检清单:name 是展示名,cmd 是实际命令,probe 是版本探测参数
19
+ DOCTOR_SPECS: list[dict[str, Any]] = [
20
+ {"name": "claude", "cmd": "claude", "probe": ["--version"]},
21
+ {"name": "codex", "cmd": "codex", "probe": ["--version"]},
22
+ {"name": "kilo", "cmd": "kilo", "probe": ["--version"]},
23
+ {"name": "mcode", "cmd": "mcode", "probe": ["--version"]},
24
+ {"name": "goose", "cmd": "goose", "probe": ["--version"]},
25
+ {"name": "opencode", "cmd": "opencode", "probe": ["--version"]},
26
+ {"name": "qoder", "cmd": "qoderclicn", "probe": ["--version"]},
27
+ {"name": "crush", "cmd": "crush", "probe": ["--version"]},
28
+ {"name": "hermes", "cmd": "hermes", "probe": ["--version"]},
29
+ ]
30
+
31
+ FORM_MISSING = "未找到"
32
+ FORM_NATIVE = "原生 exe(直调)"
33
+ FORM_DIRECT_OK = "直调可用(自动命中同名 exe)"
34
+ FORM_SHIM = "npm shim(需 cmd /c 包装)"
35
+ FORM_INSTALLED = "已安装(直调)"
36
+
37
+
38
+ def _find_exe(cmd: str) -> str | None:
39
+ """按 CreateProcess 语义搜 PATH:Windows 找 <cmd>.exe(shim 不算数);含路径分隔符按原样判定。"""
40
+ if os.sep in cmd or (os.altsep and os.altsep in cmd):
41
+ return cmd if os.path.isfile(cmd) else None
42
+ if os.name != "nt":
43
+ return shutil.which(cmd)
44
+ for d in os.environ.get("PATH", "").split(os.pathsep):
45
+ if not d:
46
+ continue
47
+ candidate = os.path.join(d, cmd + ".exe")
48
+ if os.path.isfile(candidate):
49
+ return candidate
50
+ return None
51
+
52
+
53
+ def _first_line(res: Any) -> str:
54
+ """版本行:stdout 首行优先,stderr 兜底(部分 CLI 把版本打到 stderr)。"""
55
+ src = (res.stdout_tail or "").strip().splitlines() or (res.stderr_tail or "").strip().splitlines()
56
+ return src[0].strip()[:80] if src else ""
57
+
58
+
59
+ def _probe_one(cmd: str, probe: list[str], timeout: float) -> dict[str, Any]:
60
+ """实跑探测:网关直调;Windows 下确认 shim 文件存在才用 cmd /c 复核(cmd 自身对
61
+ 不存在命令也能启动且退出非零,不能拿它判定形态)。"""
62
+ res = run([cmd] + probe, timeout=timeout)
63
+ if res.state == "not_found" and os.name == "nt":
64
+ shim = shutil.which(cmd)
65
+ if shim and shim.lower().endswith((".cmd", ".bat")):
66
+ res = run(["cmd", "/c", cmd] + probe, timeout=timeout)
67
+ return {
68
+ "cmd": cmd,
69
+ "form": FORM_SHIM,
70
+ "ok": res.exit_code == 0 and res.state == "completed",
71
+ "exit_code": res.exit_code,
72
+ "state": res.state,
73
+ "version": _first_line(res),
74
+ "duration_ms": res.duration_ms,
75
+ "log_path": res.log_path,
76
+ "path": shim,
77
+ }
78
+ if res.state in ("not_found", "spawn_error"):
79
+ form = FORM_MISSING
80
+ else:
81
+ form = FORM_NATIVE if _find_exe(cmd) else FORM_DIRECT_OK
82
+ return {
83
+ "cmd": cmd,
84
+ "form": form,
85
+ "ok": res.exit_code == 0 and res.state == "completed",
86
+ "exit_code": res.exit_code,
87
+ "state": res.state,
88
+ "version": _first_line(res),
89
+ "duration_ms": res.duration_ms,
90
+ "log_path": res.log_path,
91
+ "path": shutil.which(cmd) or "",
92
+ }
93
+
94
+
95
+ def _static_one(cmd: str) -> dict[str, Any]:
96
+ """静态快查:只按 PATH 判形态,不实跑(--no-probe;shim 判定对同名 exe 共存场景可能不准)。"""
97
+ exe_hit = _find_exe(cmd)
98
+ which_hit = shutil.which(cmd)
99
+ if exe_hit:
100
+ form, ok = FORM_NATIVE, True
101
+ elif os.name == "nt" and which_hit:
102
+ form, ok = FORM_SHIM, True
103
+ elif which_hit:
104
+ form, ok = FORM_INSTALLED, True
105
+ else:
106
+ form, ok = FORM_MISSING, False
107
+ return {
108
+ "cmd": cmd,
109
+ "form": form,
110
+ "ok": ok,
111
+ "exit_code": None,
112
+ "state": "static",
113
+ "version": "",
114
+ "duration_ms": None,
115
+ "log_path": "",
116
+ "path": which_hit or "",
117
+ }
118
+
119
+
120
+ def doctor(commands: list[str] | None = None, probe: bool = True, timeout: float = 20.0) -> list[dict[str, Any]]:
121
+ """体检本机 CLI,返回结构化行。commands 为空则用内置 9 个编码 CLI 清单。"""
122
+ if commands:
123
+ specs: list[dict[str, Any]] = [{"name": c, "cmd": c, "probe": ["--version"]} for c in commands]
124
+ else:
125
+ specs = DOCTOR_SPECS
126
+ rows = []
127
+ for spec in specs:
128
+ if probe:
129
+ row = _probe_one(spec["cmd"], list(spec["probe"]), timeout)
130
+ else:
131
+ row = _static_one(spec["cmd"])
132
+ rows.append({"name": spec["name"], **row})
133
+ return rows