ot-cli 0.2.1__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.
Files changed (91) hide show
  1. openterminal/__init__.py +3 -0
  2. openterminal/agent/__init__.py +39 -0
  3. openterminal/agent/backend.py +180 -0
  4. openterminal/agent/build.py +115 -0
  5. openterminal/agent/middleware.py +111 -0
  6. openterminal/agent/model.py +41 -0
  7. openterminal/agent/prompt.py +63 -0
  8. openterminal/agent/runner.py +340 -0
  9. openterminal/agent/sysprobe.py +164 -0
  10. openterminal/agent/token_est.py +49 -0
  11. openterminal/app.py +52 -0
  12. openterminal/approval.py +7 -0
  13. openterminal/backend.py +3 -0
  14. openterminal/banner.py +7 -0
  15. openterminal/cli.py +426 -0
  16. openterminal/cmdset.py +7 -0
  17. openterminal/config.py +322 -0
  18. openterminal/connections/__init__.py +20 -0
  19. openterminal/connections/db.py +240 -0
  20. openterminal/connections/models.py +289 -0
  21. openterminal/connections/secrets.py +68 -0
  22. openterminal/connections_db.py +6 -0
  23. openterminal/core/__init__.py +41 -0
  24. openterminal/core/ai_flow.py +781 -0
  25. openterminal/core/auth.py +499 -0
  26. openterminal/core/cmdset_runner.py +352 -0
  27. openterminal/core/exec_events.py +650 -0
  28. openterminal/core/history.py +79 -0
  29. openterminal/core/hook_gate.py +415 -0
  30. openterminal/core/messages.py +193 -0
  31. openterminal/core/nested_ssh.py +178 -0
  32. openterminal/core/pipeline.py +1187 -0
  33. openterminal/core/pump.py +190 -0
  34. openterminal/core/transcript.py +26 -0
  35. openterminal/frontend/__init__.py +21 -0
  36. openterminal/frontend/approval.py +40 -0
  37. openterminal/frontend/banner.py +40 -0
  38. openterminal/frontend/headless.py +290 -0
  39. openterminal/frontend/render.py +41 -0
  40. openterminal/frontend/taskview.py +184 -0
  41. openterminal/frontend/term.py +1417 -0
  42. openterminal/headless.py +7 -0
  43. openterminal/history_db.py +7 -0
  44. openterminal/local_pty.py +3 -0
  45. openterminal/local_win.py +3 -0
  46. openterminal/policy.py +362 -0
  47. openterminal/render.py +7 -0
  48. openterminal/secrets_store.py +5 -0
  49. openterminal/session/__init__.py +17 -0
  50. openterminal/session/base.py +328 -0
  51. openterminal/session/local_posix.py +184 -0
  52. openterminal/session/local_win.py +173 -0
  53. openterminal/session/ssh.py +321 -0
  54. openterminal/shell/__init__.py +17 -0
  55. openterminal/shell/cmdset.py +49 -0
  56. openterminal/shell/integration.py +1298 -0
  57. openterminal/shell_integration.py +7 -0
  58. openterminal/shell_session.py +8 -0
  59. openterminal/skills/__init__.py +45 -0
  60. openterminal/skills/markdown-tables/SKILL.md +247 -0
  61. openterminal/ssh_pty.py +6 -0
  62. openterminal/sysprobe.py +7 -0
  63. openterminal/taskview.py +7 -0
  64. openterminal/term_frontend.py +7 -0
  65. openterminal/token_est.py +3 -0
  66. openterminal/transcript.py +7 -0
  67. openterminal/web/__init__.py +1 -0
  68. openterminal/web/files_api.py +789 -0
  69. openterminal/web/frontend/index.html +37 -0
  70. openterminal/web/frontend/static/app.js +3632 -0
  71. openterminal/web/frontend/static/block_stream.js +274 -0
  72. openterminal/web/frontend/static/block_view.js +383 -0
  73. openterminal/web/frontend/static/files_panel.js +782 -0
  74. openterminal/web/frontend/static/md_lite.js +127 -0
  75. openterminal/web/frontend/static/modal.js +122 -0
  76. openterminal/web/frontend/static/pad_ledger.js +46 -0
  77. openterminal/web/frontend/static/style.css +915 -0
  78. openterminal/web/frontend/static/ui/ui.css +1 -0
  79. openterminal/web/frontend/static/ui/ui.js +6292 -0
  80. openterminal/web/frontend/vendor/addon-fit.js +2 -0
  81. openterminal/web/frontend/vendor/xterm.css +8 -0
  82. openterminal/web/frontend/vendor/xterm.js +8 -0
  83. openterminal/web/protocol.py +41 -0
  84. openterminal/web/server.py +322 -0
  85. openterminal/web/worker.py +62 -0
  86. ot_cli-0.2.1.dist-info/METADATA +439 -0
  87. ot_cli-0.2.1.dist-info/RECORD +91 -0
  88. ot_cli-0.2.1.dist-info/WHEEL +5 -0
  89. ot_cli-0.2.1.dist-info/entry_points.txt +3 -0
  90. ot_cli-0.2.1.dist-info/licenses/LICENSE +202 -0
  91. ot_cli-0.2.1.dist-info/top_level.txt +1 -0
@@ -0,0 +1,190 @@
1
+ """输出泵(Task 8 自 PipelineCore 原样搬入)——责任链编排。
2
+
3
+ PTY 字节块到达后的固定次序(分支顺序/判断条件/字节处理与搬移前逐字
4
+ 一致,第一版逐条委托原组件):
5
+
6
+ 字节块到达 →
7
+ 1. exec_events.strip_intr_echo() # ^C 回显吞除
8
+ 2. auth.inspect_output() # 密码提示符/验证窗/模态代关
9
+ 3. hook.observe_output() + note_mark # hook 观测/shell 退出检测
10
+ 4. exec_events.on_stream_event/feed # exec 标记流(OSC 切分)
11
+ 5. live 透传(emit_bytes + 幕帘扣留)
12
+
13
+ 泵任务体 _pump_loop 挂 _pump_guard 死亡留痕(引用被 core._pump 持有,
14
+ asyncio 不报「未取回异常」——真机「终端彻底沉默」盲区)。
15
+ 状态**物理存 core 实例**(P3 裁定延续:close/_switch_target/测试直读
16
+ core._pump/_sender;OutputPump.__init__ 以 hasattr 守卫补建,预置值
17
+ 不覆盖),方法体经 self.core 访问,phase 只读转发 core._pump_phase。
18
+ _reap_zombie_ai(读 _ai_ping_at)与 _pump_eof 留 core(任务生命周期域)。
19
+ """
20
+
21
+ from __future__ import annotations
22
+
23
+ import asyncio
24
+
25
+ from .messages import ServerMsg, _dbg
26
+
27
+
28
+ class OutputPump:
29
+ def __init__(self, core) -> None:
30
+ self.core = core
31
+ for name, val in (
32
+ ("_sender", None), # 出站发送任务(attach 起,close 自然退)
33
+ ("_pump", None), # 输出泵任务(连接就绪起)
34
+ ("_pump_phase", "read"), # 排障:泵当前所处 stage(心跳打出)
35
+ ):
36
+ if not hasattr(core, name):
37
+ setattr(core, name, val)
38
+
39
+ # --- 组件公开接口 ---
40
+ def start(self) -> None:
41
+ """起 _pump/_sender/_heartbeat(已运行则跳过;连接后语境调用)。"""
42
+ c = self.core
43
+ if c._sender is None:
44
+ c._sender = asyncio.create_task(self._sender_loop())
45
+ if c._hb is None:
46
+ c._hb = asyncio.create_task(self._heartbeat())
47
+ if c._pump is None or c._pump.done():
48
+ c._pump = asyncio.create_task(self._pump_guard())
49
+ if getattr(c, "_loop_beat", None) is None:
50
+ c._loop_beat = asyncio.create_task(self._loop_watchdog())
51
+
52
+ @property
53
+ def phase(self) -> str:
54
+ """泵当前所处 stage(排障心跳打出)。"""
55
+ return self.core._pump_phase
56
+
57
+ async def _pump_guard(self) -> None:
58
+ """泵死亡留痕:任务引用被 self.core._pump 持有,异常永远不会被 asyncio 当作
59
+ 「未取回」打印出来——真机「终端彻底不再回显、日志一片安静」就此查无
60
+ 实据(第 3 轮复现里降级路径之后 90s 零字节的盲区)。"""
61
+ try:
62
+ await self._pump_loop()
63
+ except asyncio.CancelledError:
64
+ raise
65
+ except BaseException: # noqa: BLE001 - 只为留痕,原样重抛
66
+ import traceback
67
+ _dbg("pump died:", traceback.format_exc())
68
+ raise
69
+ async def _loop_watchdog(self) -> None:
70
+ """事件循环活性探针:2s 一跳,停跳即证明循环阻塞(阻塞点=最后日志后)。
71
+ 真机「连接无响应」根因排查用——普通日志看不出循环死活。"""
72
+ import time as _t
73
+ n = 0
74
+ while not self.core._closed:
75
+ await asyncio.sleep(2)
76
+ n += 1
77
+ _dbg("loop beat", n)
78
+
79
+ async def _pump_loop(self) -> None:
80
+ """PTY 泵:回退/Shell 模式纯透传;集成模式下按 OSC 标记切分——
81
+ 标记字节与需吞除的注入回显不外发,其余原样转发(唯一显示管线)。"""
82
+ _tick = 0
83
+ while not self.core._closed:
84
+ try:
85
+ data = await self.core.session._read_some(0.2)
86
+ except asyncio.TimeoutError:
87
+ # 心跳:读超时是稳态,每 10 次(≈2s)留一行。它把「远端真的
88
+ # 沉默」与「泵死了/卡在处理器里」分开——心跳仍在 = 泵活着且
89
+ # _read_some 正常返回,字节确实没来(第 3 轮真机盲区)。
90
+ _tick += 1
91
+ if _tick % 10 == 0:
92
+ _dbg("pump tick", _tick, "phase=", self.core._pump_phase)
93
+ continue
94
+ except (ConnectionError, EOFError):
95
+ await self.core._pump_eof("连接已断开")
96
+ return
97
+ if data == b"": # 本地 PTY 读者线程的 EOF 哨兵(如用户 exit)
98
+ await self.core._pump_eof("会话已结束")
99
+ return
100
+ if not data:
101
+ continue
102
+ self.core._pump_phase = "body"
103
+ _dbg("raw", len(data), repr(data[:60]))
104
+ if self.core._cs_tail is not None:
105
+ self.core._cs_tail.extend(data)
106
+ if len(self.core._cs_tail) > 8192:
107
+ del self.core._cs_tail[:-8192] # 只留尾部:提示匹配只需最近输出
108
+ if self.core._intr_swallow_until:
109
+ data = self.core.exec._strip_intr_echo(data)
110
+ if not data:
111
+ _dbg("intr swallow: 整块被 ^C 回显吞除")
112
+ continue
113
+ self.core.auth.inspect_output(data)
114
+ await self.core.hook._detect_shell_exit(data)
115
+ if not self.core._interactive or self.core._display == "ssh":
116
+ self.core._pump_phase = "passthru"
117
+ await self.core.emit_bytes(data) # 回退模型 / Shell 模式:纯透传
118
+ continue
119
+ for ev in self.core._router.feed(data):
120
+ self.core._pump_phase = "ev:" + str(ev[0])
121
+ await self.core.exec.on_stream_event(ev)
122
+ self.core._pump_phase = "read"
123
+ async def _stop_pump(self) -> None:
124
+ """停输出泵并等它真正退出。只 cancel 不等的话,泵可能还压在
125
+ _read_some 上,抢走随后命令/探测的回显与哨兵字节(哨兵流被切坏
126
+ → run 拿空输出)。"""
127
+ if self.core._pump is not None:
128
+ t, self.core._pump = self.core._pump, None
129
+ t.cancel()
130
+ try:
131
+ await t
132
+ except asyncio.CancelledError:
133
+ # 外层任务在 `await t` 上被取消时,取消会传导给泵任务
134
+ # (await task 的 waiter 就是任务本身),泵的 CancelledError
135
+ # 会从这里冒出来——不能吞,否则外层的取消也被吞掉,任务
136
+ # 变成杀不死的僵尸(teardown gather 挂死)
137
+ if asyncio.current_task().cancelling() > 0:
138
+ raise
139
+ except Exception: # noqa: BLE001 - 会话关闭竞态(fd 失效等)
140
+ pass
141
+ async def _heartbeat(self) -> None:
142
+ """应用层心跳:5s 一个 ping,给前端看门狗当「服务端还活着」基线。
143
+ **直发 sink 绕过 outbox**:outbox/sender 卡死时 ping 若也排队,前端就
144
+ 误判「连接无响应」重连(真机 R8 稳定挂根因)。ping 直达 sink 保证
145
+ 只要 WS 没死前端就看得到心跳;sender 卡死时其余帧照常排队。
146
+ 5s(原 10s):输出密集期 outbox 排队会让排队的 ping 迟到。"""
147
+ while not self.core._closed:
148
+ await asyncio.sleep(5)
149
+ if self.core._closed:
150
+ break
151
+ # 直发 sink:不经 outbox,不与业务帧抢 sender
152
+ sink = self.core._sink
153
+ if sink is not None:
154
+ try:
155
+ from ..web.protocol import encode_server
156
+ from ..core.messages import ServerMsg
157
+ await asyncio.wait_for(
158
+ sink.send_text(encode_server(ServerMsg(type="ping"))),
159
+ timeout=3)
160
+ except Exception: # noqa: BLE001 - sink 死/慢:走 outbox 兜底
161
+ self.core._emit_nowait(ServerMsg(type="ping"))
162
+ else:
163
+ self.core._emit_nowait(ServerMsg(type="ping"))
164
+ await self.core._reap_zombie_ai()
165
+ async def _sender_loop(self) -> None:
166
+ while True:
167
+ kind, payload = await self.core._outbox.get()
168
+ sink = self.core._sink
169
+ if sink is None:
170
+ self.core._buffer_offline(kind, payload)
171
+ else:
172
+ try:
173
+ if kind == "bytes":
174
+ await sink.send_bytes(payload)
175
+ else:
176
+ await sink.send_text(payload)
177
+ except Exception:
178
+ # sink 已死但 detach 尚未回来(网络刚断的那几毫秒):帧必须
179
+ # 攒住,否则断线瞬间发出的收束帧就永久丢了(旧实现 continue
180
+ # 直接丢弃)。顺手摘掉死 sink,后续帧不再逐条撞异常。
181
+ if self.core._sink is sink:
182
+ self.core._sink = None
183
+ self.core._buffer_offline(kind, payload)
184
+ # 关闭后把剩余消息发完(含 closed)再退出,避免协程泄漏。
185
+ # 只看 _closed 不够:close 会先置 _closed 再入队 closed,若此刻
186
+ # backlog 未排空,sender 会在 closed 入队前按 empty 提前退出
187
+ if self.core._closed and self.core._closed_sent and self.core._outbox.empty():
188
+ break
189
+
190
+ # --- 主流程 ---
@@ -0,0 +1,26 @@
1
+ """会话 JSONL 记录。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import datetime as dt
6
+ import json
7
+ from pathlib import Path
8
+
9
+ from ..config import app_dir
10
+
11
+
12
+ class Transcript:
13
+ def __init__(self, path: Path) -> None:
14
+ self.path = path
15
+ self.path.parent.mkdir(parents=True, exist_ok=True)
16
+
17
+ def append(self, kind: str, **fields) -> None:
18
+ row = {"ts": dt.datetime.now().isoformat(timespec="seconds"),
19
+ "kind": kind, **fields}
20
+ with self.path.open("a", encoding="utf-8") as f:
21
+ f.write(json.dumps(row, ensure_ascii=False) + "\n")
22
+
23
+
24
+ def open_transcript(session_id: str) -> Transcript:
25
+ day = dt.date.today().isoformat()
26
+ return Transcript(app_dir() / "sessions" / day / f"{session_id}.jsonl")
@@ -0,0 +1,21 @@
1
+ """frontend 域(阶段二 Task 15 重组)。
2
+
3
+ term.py——终端交互前端(TermFrontend/CliCore/CliRenderer,输入捕获与
4
+ 状态栏);approval.py——审批决定归一化;render.py——Console 与命令/
5
+ 结果渲染;banner.py——启动横幅;taskview.py——任务呈现(TaskPresenter/
6
+ ThinkingStream);headless.py——无头执行模式(退出码约定/目标解析)。
7
+ 本 __init__ 再导出旧六模块全部公开名,旧 import 路径
8
+ (``from openterminal.term_frontend import ...`` 等)不因拆包而变。
9
+ """
10
+
11
+ from .approval import decision_for_choice, edit_decision, reclassify_edited # noqa: F401
12
+ from .banner import print_banner # noqa: F401
13
+ from .headless import ( # noqa: F401
14
+ DEFAULT_EXEC_TIMEOUT, EXIT_APPROVAL_REQUIRED, EXIT_CONNECT_FAILED,
15
+ EXIT_DENIED, EXIT_HOST_KEY, EXIT_TARGET_NOT_FOUND, EXIT_USAGE,
16
+ all_targets, resolve_target, run_headless,
17
+ )
18
+ from .render import console, print_answer, print_command, print_denied, \
19
+ print_summary # noqa: F401
20
+ from .taskview import TaskPresenter, ThinkingStream # noqa: F401
21
+ from .term import CliCore, CliRenderer, TermFrontend # noqa: F401
@@ -0,0 +1,40 @@
1
+ """审批交互的纯决策部分;TUI 提示在 cli.py。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Literal
6
+
7
+ from ..policy import Decision, Policy
8
+
9
+ Choice = Literal["y", "e", "n", "a"]
10
+
11
+
12
+ def reclassify_edited(new_command: str, policy: Policy) -> Decision:
13
+ """编辑后的命令必须重新过一遍策略。"""
14
+ return policy.classify(new_command)
15
+
16
+
17
+ def decision_for_choice(
18
+ choice: str,
19
+ command: str,
20
+ policy: Policy,
21
+ allowed: set[str],
22
+ ) -> dict | None:
23
+ if choice == "y":
24
+ return {"type": "approve"}
25
+ if choice == "a":
26
+ allowed.add(command)
27
+ return {"type": "approve"}
28
+ if choice == "n":
29
+ return {"type": "reject", "message": "用户拒绝了该命令"}
30
+ return None # e:由 CLI 收集编辑后的命令
31
+
32
+
33
+ def edit_decision(new_command: str) -> dict:
34
+ return {
35
+ "type": "edit",
36
+ "edited_action": {
37
+ "name": "execute",
38
+ "args": {"command": new_command},
39
+ },
40
+ }
@@ -0,0 +1,40 @@
1
+ """启动欢迎横幅(Spring Boot banner 风格)。
2
+
3
+ 字形由 pyfiglet "standard" 字体生成后固化为常量——运行时不引入该依赖。
4
+ """
5
+
6
+ from __future__ import annotations
7
+
8
+ import sys
9
+
10
+ from rich.text import Text
11
+
12
+ from .render import console
13
+
14
+ _BANNER = r""" _ _ _
15
+ ___ _ __ ___ _ __ | |_ ___ _ __ _ __ ___ (_)_ __ __ _| |
16
+ / _ \| '_ \ / _ \ '_ \| __/ _ \ '__| '_ ` _ \| | '_ \ / _` | |
17
+ | (_) | |_) | __/ | | | || __/ | | | | | | | | | | | (_| | |
18
+ \___/| .__/ \___|_| |_|\__\___|_| |_| |_| |_|_|_| |_|\__,_|_|
19
+ |_| """
20
+
21
+
22
+ def _version() -> str:
23
+ try:
24
+ from importlib.metadata import version
25
+
26
+ return version("openterminal")
27
+ except Exception:
28
+ return "0.1.0"
29
+
30
+
31
+ def print_banner() -> None:
32
+ """打印 ASCII 横幅 + 版本信息行。无头模式(exec/list)不要调用。"""
33
+ console.print(Text("\n" + _BANNER, style="bold green"))
34
+ label, ver = " :: OpenTerminal ::", f"(v{_version()})"
35
+ console.print(
36
+ label + " " * (len(_BANNER.splitlines()[1]) - len(label) - len(ver))
37
+ + ver + f" · Python {sys.version.split()[0]}",
38
+ style="green",
39
+ )
40
+ console.print()
@@ -0,0 +1,290 @@
1
+ """无头执行面:ot exec / ot list——供外部 AI 编程工具(Claude Code、opencode
2
+ 等)以子进程方式调用,无需进入 REPL。
3
+
4
+ 设计对齐"Agent 优先"原则:
5
+ - --output json 输出结构化结果,退出码分层:远程命令退出码原样透传(< 64),
6
+ 工具自身错误使用 ≥ 64 的保留码,Agent 可据此区分"远程成败"与"调用问题"。
7
+ - 审批不走交互:Policy 分级映射为协议行为——auto 执行、deny/APPROVAL
8
+ 结构化拒绝,出路只有"人的动作"(交互界面记住允许 / 编辑 config)。
9
+ - 主机指纹沿用 TOFU,但无头下不询问:known_hosts 未收录即结构化报错,
10
+ 请用户先在交互界面连接一次。
11
+
12
+ 退出码表:
13
+ 0/1~63 远程命令退出码透传(与 SSH 行为一致);远程码若恰好落在
14
+ 64~69 工具保留段则收敛为 63——保证「≥64 必是工具问题」的
15
+ 判别契约,真实值仍在 JSON payload 的 exit_code 字段里
16
+ 64 用法错误
17
+ 65 目标不存在
18
+ 66 策略拒绝(deny)
19
+ 67 需要用户审批(approve)
20
+ 68 连接/认证失败
21
+ 69 主机指纹未收录(TOFU)
22
+ 124 超时(由会话层哨兵超时返回,经透传路径自然到达)
23
+ """
24
+
25
+ from __future__ import annotations
26
+
27
+ import argparse
28
+ import asyncio
29
+ import difflib
30
+ import json
31
+ import sys
32
+
33
+ from ..config import Config, TargetConfig
34
+
35
+ EXIT_USAGE = 64
36
+ EXIT_TARGET_NOT_FOUND = 65
37
+ EXIT_DENIED = 66
38
+ EXIT_APPROVAL_REQUIRED = 67
39
+ EXIT_CONNECT_FAILED = 68
40
+ EXIT_HOST_KEY = 69
41
+
42
+ DEFAULT_EXEC_TIMEOUT = 30
43
+
44
+
45
+ # ---------------------------------------------------------------------------
46
+ # 目标解析
47
+ # ---------------------------------------------------------------------------
48
+
49
+ def all_targets(cfg: Config) -> list[TargetConfig]:
50
+ """配置文件目标 + 记住的连接(记住的靠前展示,按 host 去重)。
51
+
52
+ config.toml 手写目标**不被 host 去重吞掉**——记住的连接里有同址条目
53
+ 时(如手敲 root@1.2.3.4 记住后),同名之外的手写目标(prod 等别名)
54
+ 仍须可被 `ot exec -t prod` 解析到;仅同名时跳过(记住的展示优先)。
55
+ """
56
+ saved = []
57
+ try:
58
+ from ..connections import load_saved_targets
59
+
60
+ saved = load_saved_targets()
61
+ except Exception:
62
+ pass # 凭据库不可用时仍可使用 config.toml 里的目标
63
+ seen_keys: set[tuple] = set()
64
+ seen_names: set[str] = set()
65
+ merged: list[TargetConfig] = []
66
+ for t in saved:
67
+ key = (t.mode, t.host or t.name, t.user, t.port)
68
+ if key in seen_keys or t.name in seen_names:
69
+ continue
70
+ seen_keys.add(key)
71
+ seen_names.add(t.name)
72
+ merged.append(t)
73
+ for t in cfg.targets.values():
74
+ if t.name in seen_names:
75
+ continue
76
+ seen_names.add(t.name)
77
+ merged.append(t)
78
+ return merged
79
+
80
+
81
+ def resolve_target(name: str, cfg: Config) -> tuple[TargetConfig | None, list[str]]:
82
+ """按名字解析目标:精确 → 模糊。返回 (目标 | None, 候选名列表)。"""
83
+ targets = all_targets(cfg)
84
+ names = [t.name for t in targets]
85
+ for t in targets:
86
+ if t.name == name:
87
+ return t, names
88
+ return None, difflib.get_close_matches(name, names, n=5, cutoff=0.6)
89
+
90
+
91
+ # ---------------------------------------------------------------------------
92
+ # JSON 输出
93
+ # ---------------------------------------------------------------------------
94
+
95
+ def _emit(payload: dict, *, as_json: bool, file) -> None:
96
+ if as_json:
97
+ json.dump(payload, file, ensure_ascii=False, indent=2)
98
+ file.write("\n")
99
+ else:
100
+ file.write(payload.get("message") or payload.get("output") or "")
101
+
102
+
103
+ # ---------------------------------------------------------------------------
104
+ # ot exec
105
+ # ---------------------------------------------------------------------------
106
+
107
+ def _gate(command: str, cfg: Config) -> tuple[int, dict] | None:
108
+ """Policy 分级 → 无头协议行为。返回 (退出码, 错误 payload) 或 None(放行)。"""
109
+ from ..policy import Policy
110
+
111
+ p = cfg.policy
112
+ decision = Policy(
113
+ mode=p.mode,
114
+ auto_extra=p.auto_extra,
115
+ approve_extra=p.approve_extra,
116
+ deny_extra=p.deny_extra,
117
+ ).classify(command)
118
+ if decision.level == "auto":
119
+ return None
120
+ if decision.level == "deny":
121
+ return EXIT_DENIED, {
122
+ "code": "DENIED",
123
+ "command": command,
124
+ "reasons": decision.reasons,
125
+ "message": f"策略拒绝执行该命令:{';'.join(decision.reasons)}",
126
+ }
127
+ return EXIT_APPROVAL_REQUIRED, {
128
+ "code": "APPROVAL_REQUIRED",
129
+ "command": command,
130
+ "reasons": decision.reasons,
131
+ "hint": "无头模式不执行需人工审批的命令。请让用户在 ot 交互界面执行一次"
132
+ "并选择『记住允许』,或将其加入 config.toml 的 policy.auto_extra。",
133
+ "message": f"该命令需要用户审批:{';'.join(decision.reasons)}",
134
+ }
135
+
136
+
137
+ async def _exec_async(target: TargetConfig, command: str, *, timeout: int) -> int:
138
+ from ..connections import open_session
139
+ from ..connections.secrets import load_password_alias
140
+
141
+ # 只有 SSH 目标才查凭据库:local 目标 host=None,按 None 拼 keyring
142
+ # 键纯属噪音(还可能撞上 keyring 后端的空键校验)。别名互查与
143
+ # _connect 首跳同语义(None/22 同键)。
144
+ password = (load_password_alias(target.host, target.user, target.port)
145
+ if target.mode == "ssh" and target.host else None)
146
+ session = await open_session(
147
+ target,
148
+ password=password,
149
+ # 无头模式不交互:密码提示直接失败,主机指纹预先检查
150
+ password_prompt=lambda label: None,
151
+ host_key_prompt=lambda message: False,
152
+ )
153
+ try:
154
+ result = await session.run(command, timeout=timeout)
155
+ return result
156
+ finally:
157
+ await session.close()
158
+
159
+
160
+ def cmd_exec(args: argparse.Namespace, cfg: Config, out, err) -> int:
161
+ command = args.command
162
+ if not command:
163
+ _emit({"code": "USAGE", "message": "缺少 -c/--command"}, as_json=args.json, file=err)
164
+ return EXIT_USAGE
165
+
166
+ target, candidates = resolve_target(args.target, cfg)
167
+ if target is None:
168
+ payload = {"code": "TARGET_NOT_FOUND",
169
+ "message": f"目标 '{args.target}' 不存在。"}
170
+ if candidates:
171
+ payload["candidates"] = candidates
172
+ payload["message"] += f" 相近的可用目标: {', '.join(candidates)}"
173
+ _emit(payload, as_json=args.json, file=err)
174
+ return EXIT_TARGET_NOT_FOUND
175
+
176
+ verdict = _gate(command, cfg)
177
+ if verdict is not None:
178
+ exit_code, payload = verdict
179
+ _emit(payload, as_json=args.json, file=err)
180
+ return exit_code
181
+
182
+ # 主机指纹 TOFU 预检:无头模式不询问,未收录直接报错
183
+ if target.mode == "ssh":
184
+ from ..session.ssh import known_hosts_has_entry, known_hosts_path
185
+
186
+ host = target.host or target.name
187
+ if not known_hosts_has_entry(known_hosts_path(), host, target.port):
188
+ _emit({"code": "HOST_KEY_UNKNOWN", "host": host,
189
+ "hint": "首次连接请先在 ot 交互界面完成指纹确认(TOFU)。",
190
+ "message": f"known_hosts 中没有主机 {host} 的指纹记录。"},
191
+ as_json=args.json, file=err)
192
+ return EXIT_HOST_KEY
193
+
194
+ try:
195
+ result = asyncio.run(_exec_async(target, command, timeout=args.timeout))
196
+ except Exception as e:
197
+ _emit({"code": "CONNECT_FAILED", "target": target.name,
198
+ "message": f"连接或执行失败:{type(e).__name__}: {e}"},
199
+ as_json=args.json, file=err)
200
+ return EXIT_CONNECT_FAILED
201
+
202
+ payload = {"output": result.output,
203
+ "exit_code": result.exit_code,
204
+ "truncated": result.truncated,
205
+ "cwd": result.cwd,
206
+ "target": target.name}
207
+ _emit(payload, as_json=args.json, file=out)
208
+ rc = result.exit_code & 0xFF # 与 shell 退出码语义一致(0~255)
209
+ if 64 <= rc <= 69:
210
+ # 远程命令退出码撞上工具保留段:进程码收敛为 63(远程失败),
211
+ # 保住「≥64 必是工具问题」的契约;真实值在 JSON exit_code 里
212
+ rc = 63
213
+ return rc
214
+
215
+
216
+ # ---------------------------------------------------------------------------
217
+ # ot list targets
218
+ # ---------------------------------------------------------------------------
219
+
220
+ def cmd_list(args: argparse.Namespace, cfg: Config, out, err) -> int:
221
+ if args.what != "targets":
222
+ _emit({"code": "USAGE", "message": f"未知子项 '{args.what}',可用: targets"},
223
+ as_json=args.json, file=err)
224
+ return EXIT_USAGE
225
+ targets = [{"name": t.name, "mode": t.mode, "host": t.host or t.name,
226
+ "user": t.user, "port": t.port}
227
+ for t in all_targets(cfg)]
228
+ if args.json:
229
+ json.dump({"targets": targets}, out, ensure_ascii=False, indent=2)
230
+ out.write("\n")
231
+ else:
232
+ for t in targets:
233
+ where = t["user"] + "@" if t["user"] else ""
234
+ where += t["host"]
235
+ if t["port"]:
236
+ where += f":{t['port']}"
237
+ out.write(f"{t['name']}\t{where}\n")
238
+ return 0
239
+
240
+
241
+ # ---------------------------------------------------------------------------
242
+ # 入口
243
+ # ---------------------------------------------------------------------------
244
+
245
+ def build_parser() -> argparse.ArgumentParser:
246
+ parser = argparse.ArgumentParser(
247
+ prog="ot",
248
+ description="无头执行面:供脚本与 AI 编程工具调用。无参数时进入交互 REPL。",
249
+ )
250
+ sub = parser.add_subparsers(dest="subcommand", required=True)
251
+
252
+ p_exec = sub.add_parser(
253
+ "exec", help="在目标上执行一条命令并返回结果",
254
+ epilog="退出码:<64 为远程命令退出码透传;64 用法 65 目标不存在 "
255
+ "66 策略拒绝 67 需审批 68 连接失败 69 指纹未收录 124 超时。"
256
+ "失败详情看 --output json 的 code/message 字段。",
257
+ )
258
+ p_exec.add_argument("-t", "--target", required=True, help="目标名(ot list targets 可查)")
259
+ p_exec.add_argument("-c", "--command", required=True, help="要执行的命令")
260
+ p_exec.add_argument("--timeout", type=int, default=DEFAULT_EXEC_TIMEOUT,
261
+ help=f"超时秒数(默认 {DEFAULT_EXEC_TIMEOUT})")
262
+ p_exec.add_argument("--output", "-o", choices=["text", "json"], default="text",
263
+ help="输出格式,Agent 建议用 json")
264
+
265
+ p_list = sub.add_parser("list", help="列举资源")
266
+ # 不用 argparse choices:未知子项要走结构化 USAGE 错误(cmd_list 校验),
267
+ # 否则 argparse 直接 SystemExit(2),Agent 拿不到 JSON
268
+ p_list.add_argument("what", nargs="?", default="targets",
269
+ help="targets:可用目标列表")
270
+ p_list.add_argument("--output", "-o", choices=["text", "json"], default="text",
271
+ help="输出格式")
272
+ for p in (p_exec, p_list):
273
+ p.set_defaults(json=False)
274
+ # argparse 处理 -o json 与 --output 同义;json 布尔由 output 推导
275
+ return parser
276
+
277
+
278
+ def run_headless(argv: list[str], cfg: Config | None = None,
279
+ out=None, err=None) -> int:
280
+ """无头入口。cfg/out/err 可注入(测试用)。返回进程退出码。"""
281
+ out = out if out is not None else sys.stdout
282
+ err = err if err is not None else sys.stderr
283
+ cfg = cfg if cfg is not None else Config.load()
284
+ parser = build_parser()
285
+ args = parser.parse_args(argv)
286
+ # --output json → args.json
287
+ args.json = getattr(args, "output", "text") == "json"
288
+ if args.subcommand == "exec":
289
+ return cmd_exec(args, cfg, out, err)
290
+ return cmd_list(args, cfg, out, err)
@@ -0,0 +1,41 @@
1
+ """rich 输出助手。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ from rich.console import Console
6
+ from rich.panel import Panel
7
+ from rich.syntax import Syntax
8
+ from rich.text import Text
9
+
10
+ console = Console()
11
+
12
+
13
+ def print_command(command: str, host: str, *, approved: bool = False) -> int:
14
+ """打印命令面板;返回面板占用的屏幕行数(供审批结束后擦除)。
15
+
16
+ 行数用 capture 预渲染计数(与实际打印同一个 console,宽度一致),
17
+ 命令很长被 word_wrap 折行时同样准确。
18
+ """
19
+ tag = "执行" if approved else "将执行"
20
+ panel = Panel(Syntax(command, "bash", word_wrap=True, theme="ansi_dark"),
21
+ title=f"[bold cyan]{tag}[/] · {host}", border_style="cyan")
22
+ with console.capture() as cap:
23
+ console.print(panel)
24
+ rows = cap.get().count("\n")
25
+ console.print(panel)
26
+ return rows
27
+
28
+
29
+ def print_denied(text: str) -> None:
30
+ console.print(f"[bold red]✗ {text}[/]")
31
+
32
+
33
+ def print_summary(text: str) -> None:
34
+ """最终总结框。Text 包一层:模型输出里的 [xx] 不当 rich 标记解析。"""
35
+ console.print(Panel(Text(text), title="[bold green]📝 总结[/]",
36
+ title_align="left", border_style="green"))
37
+
38
+
39
+ def print_answer(text: str) -> None:
40
+ console.print(Panel(text, title="[bold green]OpenTerminal[/]",
41
+ border_style="green"))