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,289 @@
1
+ """SSH config 解析、目标列举、记住的连接(SQLite)与会话工厂。"""
2
+
3
+ from __future__ import annotations
4
+
5
+ import sys
6
+ from dataclasses import dataclass, field
7
+ from pathlib import Path
8
+
9
+ from . import db
10
+ from ..config import TargetConfig
11
+ from .db import RenameConflictError # noqa: F401 - 供调用方统一 import
12
+
13
+
14
+ def display_name(host: str, user: str | None, port: int | None) -> str:
15
+ """连接的展示名:user@host(非标准端口再附 :port)。"""
16
+ name = f"{user}@{host}" if user else host
17
+ return f"{name}:{port}" if port else name
18
+
19
+
20
+ # 补存/记住失败的可见提示(keyring 不可用等降级场景;文案全链路统一)
21
+ PW_STORE_FAIL_HINT = "密码未能写入系统凭据库,下次连接仍需手输"
22
+
23
+
24
+ def cred_alias_eq(a: tuple[str | None, str | None, int | None],
25
+ b: tuple[str | None, str | None, int | None]) -> bool:
26
+ """凭据三元组按 keyring 键别名语义比较:port None/22 视同相等。
27
+
28
+ display_name 只在 port 真值时拼 :port——None 与 22 会落成两个键名,
29
+ 但指向同一凭据(ssh 默认端口)。补存去重(_backfill_password)与
30
+ 共享凭据防误删(credential_referenced)都按此语义判「同一个凭据」。
31
+ """
32
+ (h1, u1, p1), (h2, u2, p2) = a, b
33
+ return (h1 == h2 and u1 == u2
34
+ and (p1 == p2 or (p1 in (None, 22) and p2 in (None, 22))))
35
+
36
+
37
+ def credential_referenced(host: str | None, user: str | None,
38
+ port: int | None) -> bool:
39
+ """凭据键 (host,user,port) 是否仍被其他记住的连接引用(别名语义)。
40
+
41
+ 删除连接 / 编辑改地址清旧凭据前的防误删判定(Web 与 CLI 共用):
42
+ 跳板机常被多条条目共享同一凭据键(三条条目同指 pe@跳板),删一条
43
+ 就清键会让其余条目跟着全部重弹密码(任务书 §2-6)。
44
+ """
45
+ key = (host, user, port)
46
+ return any(cred_alias_eq((t.host, t.user, t.port), key)
47
+ for t in load_saved_targets())
48
+
49
+
50
+ def saved_targets_path() -> Path:
51
+ """记住的连接存储位置(SQLite,用户主目录,不在 git 仓库内)。"""
52
+ return db.db_path()
53
+
54
+
55
+ def load_saved_targets(path: Path | None = None) -> list[TargetConfig]:
56
+ """读取记住的连接;库不存在/为空返回空列表。"""
57
+ conn = db.connect(path)
58
+ try:
59
+ return [TargetConfig(
60
+ name=r["name"], mode="ssh", host=r["host"] or r["name"],
61
+ user=r["user"], port=r["port"],
62
+ commands=(r["commands"] or "").splitlines(),
63
+ ) for r in db.fetch_saved(conn)]
64
+ finally:
65
+ conn.close()
66
+
67
+
68
+ def save_saved_targets(targets: list[TargetConfig],
69
+ path: Path | None = None) -> None:
70
+ """整表重写记住的连接。"""
71
+ conn = db.connect(path)
72
+ try:
73
+ db.replace_saved(conn, [
74
+ {"name": t.name, "host": t.host or t.name, "port": t.port,
75
+ "user": t.user,
76
+ "commands": "\n".join(t.commands)} for t in targets])
77
+ finally:
78
+ conn.close()
79
+
80
+
81
+ @dataclass
82
+ class TargetList:
83
+ local: bool = True
84
+ direct: list[TargetConfig] = field(default_factory=list)
85
+
86
+
87
+ # --- 单条 CRUD(Web 表单:添加 / 编辑 / 删除)---
88
+
89
+ def find_saved_target(name: str, path: Path | None = None) -> TargetConfig | None:
90
+ for t in load_saved_targets(path):
91
+ if t.name == name:
92
+ return t
93
+ return None
94
+
95
+
96
+ def upsert_saved_target(*, name: str, host: str, port: int | None = None,
97
+ user: str | None = None,
98
+ commands: str | None = None,
99
+ path: Path | None = None) -> None:
100
+ """添加/编辑一条记住的连接(按 name 唯一键 upsert)。
101
+
102
+ commands 传 None 不改动既有命令集(旧调用方兼容),空串显式清空。
103
+ """
104
+ conn = db.connect(path)
105
+ try:
106
+ db.upsert_saved(conn, {
107
+ "name": name, "host": host, "port": port, "user": user,
108
+ "commands": commands})
109
+ finally:
110
+ conn.close()
111
+
112
+
113
+ def delete_saved_target(name: str, path: Path | None = None) -> TargetConfig | None:
114
+ """删除一条记住的连接;返回被删条目(供调用方清理凭据),不存在返回 None。"""
115
+ conn = db.connect(path)
116
+ try:
117
+ row = db.delete_saved(conn, name)
118
+ finally:
119
+ conn.close()
120
+ if row is None:
121
+ return None
122
+ return TargetConfig(name=row["name"], mode="ssh", host=row["host"],
123
+ user=row["user"], port=row["port"])
124
+
125
+
126
+ def rename_saved_target(old: str, *, name: str, host: str,
127
+ port: int | None = None, user: str | None = None,
128
+ commands: str | None = None,
129
+ path: Path | None = None) -> TargetConfig | None:
130
+ """编辑连接(name 可能改):单事务删旧行、写新行;返回被删的旧行。
131
+
132
+ 旧条目不存在返回 None(不写入);新名字撞既有条目抛
133
+ RenameConflictError(库不动,Web 层映射 409 / CLI 层提示)。
134
+ """
135
+ conn = db.connect(path)
136
+ try:
137
+ row = db.rename_saved(conn, old, {
138
+ "name": name, "host": host, "port": port, "user": user,
139
+ "commands": commands})
140
+ finally:
141
+ conn.close()
142
+ if row is None:
143
+ return None
144
+ return TargetConfig(name=row["name"], mode="ssh", host=row["host"],
145
+ user=row["user"], port=row["port"])
146
+
147
+
148
+ def build_target_list(saved: list[TargetConfig] | None = None) -> TargetList:
149
+ """记住的连接即直连列表(跳板机分组已随跳板机下线移除)。"""
150
+ saved = saved if saved is not None else load_saved_targets()
151
+ return TargetList(direct=list(saved))
152
+
153
+
154
+ @dataclass
155
+ class SshTarget:
156
+ name: str
157
+ hostname: str = ""
158
+ user: str | None = None
159
+ port: int | None = None
160
+ identity: str = ""
161
+
162
+
163
+ def parse_ssh_config(path: Path) -> dict[str, SshTarget]:
164
+ """极简 ssh_config 解析器:仅取连接所需字段;首定义优先(与 OpenSSH
165
+ 一致——同名 Host 的后续块不覆盖已取到的字段)。"""
166
+ hosts: dict[str, SshTarget] = {}
167
+ current: list[str] = []
168
+ if not path.exists():
169
+ return hosts
170
+ for raw in path.read_text(encoding="utf-8").splitlines():
171
+ line = raw.strip()
172
+ if not line or line.startswith("#"):
173
+ continue
174
+ parts = line.split()
175
+ key = parts[0].lower()
176
+ if key == "host":
177
+ current = parts[1:]
178
+ for name in current:
179
+ hosts.setdefault(name, SshTarget(name=name))
180
+ continue
181
+ if not current:
182
+ continue
183
+ value = " ".join(parts[1:])
184
+ for name in current:
185
+ t = hosts[name]
186
+ if key == "hostname":
187
+ t.hostname = t.hostname or value
188
+ elif key == "user":
189
+ t.user = t.user or value
190
+ elif key == "port" and value.isdigit() and t.port is None:
191
+ t.port = int(value)
192
+ elif key == "identityfile":
193
+ t.identity = t.identity or value
194
+ return hosts
195
+
196
+
197
+ def list_ssh_hosts(path: Path | None = None) -> list[str]:
198
+ path = path or (Path.home() / ".ssh" / "config")
199
+ return [n for n in parse_ssh_config(path) if "*" not in n and "?" not in n]
200
+
201
+
202
+ def parse_user_at_host(text: str) -> tuple[str, str | None, int | None]:
203
+ user, port = None, None
204
+ host = text
205
+ if "@" in text:
206
+ user, host = text.split("@", 1)
207
+ if ":" in host:
208
+ host, p = host.rsplit(":", 1)
209
+ port = int(p) if p.isdigit() else None
210
+ return host, user, port
211
+
212
+
213
+ def _backfill_password(target: TargetConfig, session) -> bool:
214
+ """连接已记住但凭据缺失时,把本次实际使用的密码补进系统凭据库。
215
+
216
+ 记住的连接(connections.db)意味着用户已同意「记住该连接含密码」;
217
+ 缺失来自上次存库失败或凭据库当时不可用。CLI / Web / Picker 全路径
218
+ 都经 open_session,补存放这里一处生效。未记住的连接不动——那由
219
+ _maybe_remember 的 y/n 询问把关。
220
+
221
+ 已有凭据按端口别名去重(None/22 视同同一键,见
222
+ connections.secrets.load_password_alias):档案流按 port=22 存
223
+ ``user@host:22``、本次连接解析出 port=None 时,单键直查看不到别名
224
+ 键会再存一个 ``user@host``——同一密码两个键、读取侧永远错位。saved
225
+ 匹配同样按别名语义(cred_alias_eq):port None/22 视同一条连接。
226
+
227
+ 返回 True = 本该补存但写库失败(调用方给出可见提示,不再静默——
228
+ 「以为记住了其实没记住」是每次重讨密码的隐形根因);补存成功或
229
+ 无需补存均返回 False。
230
+ """
231
+ if target.mode != "ssh" or not target.host:
232
+ return False
233
+ from .secrets import load_password_alias, store_password
234
+
235
+ used = getattr(session, "last_password", None)
236
+ if used is None:
237
+ return False # 本次没用密码(密钥认证)或未记录
238
+ if load_password_alias(target.host, target.user, target.port) is not None:
239
+ return False # 已有凭据(含 None/22 别名键;旧 CLI 的失效写回路径已随换心退场)
240
+ if not any(cred_alias_eq((t.host, t.user, t.port),
241
+ (target.host, target.user, target.port))
242
+ for t in load_saved_targets()):
243
+ return False # 连接未记住:不替用户做主存密码
244
+ return not store_password(target.host, target.user, target.port, used)
245
+
246
+
247
+ async def open_session(target: TargetConfig, password: str | None = None,
248
+ *, notify=None, **kwargs_session):
249
+ """按 TargetConfig 创建会话。password 为记住的密码(可为 None);
250
+ kwargs_session 透传给会话构造器(超时/截断)。
251
+
252
+ notify:降级提示的可见化通道(同步回调,收一句文案),如 core 的
253
+ _emit_nowait(status)。补存失败经它转发 PW_STORE_FAIL_HINT——不设
254
+ 则维持旧的静默降级(headless 等无 UI 通道的调用方)。
255
+ """
256
+ # 认证回调只属于 SSH 会话;本地 PTY 构造器不接受,需先剥离再路由
257
+ prompt_kwargs = {k: kwargs_session.pop(k)
258
+ for k in ("password_prompt", "host_key_prompt")
259
+ if k in kwargs_session}
260
+ if target.mode == "local":
261
+ if sys.platform == "win32":
262
+ # Windows:ConPTY 会话(pywinpty 仅 win32 安装)
263
+ from ..session.local_win import WinPtySession
264
+
265
+ s = WinPtySession(**kwargs_session)
266
+ else:
267
+ # 延迟导入:local_pty 依赖 POSIX 专属模块(fcntl/termios),
268
+ # Windows 上只要不进本地模式就不应触发导入
269
+ from ..session.local_posix import LocalPtySession
270
+
271
+ s = LocalPtySession(**kwargs_session)
272
+ else:
273
+ from ..session.ssh import SshPtySession
274
+
275
+ host = target.host or target.name
276
+ s = SshPtySession(
277
+ host,
278
+ username=target.user,
279
+ port=target.port,
280
+ password=password,
281
+ **prompt_kwargs,
282
+ **kwargs_session,
283
+ )
284
+ await s.start()
285
+ if _backfill_password(target, s) and notify is not None:
286
+ # 补存失败不再静默:凭据库不可用时「以为记住了其实没记住」,
287
+ # 用户只能以为密码丢了反复手输(任务书 §2-5)
288
+ notify(PW_STORE_FAIL_HINT)
289
+ return s
@@ -0,0 +1,68 @@
1
+ """记住的连接密码存取:系统凭据库,经 keyring 库统一访问。
2
+
3
+ 各平台落到原生存储——Windows 凭据管理器、macOS Keychain、Linux
4
+ Secret Service(GNOME Keyring/KWallet)。凭据库不可用(如无桌面的
5
+ Linux)时静默降级:读返回 None、写为空操作,CLI 回退到连接时现场
6
+ 询问密码,功能不受影响。
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ from .models import display_name
12
+
13
+ _SERVICE = "openterminal"
14
+
15
+
16
+ def load_password(host: str, user: str | None, port: int | None) -> str | None:
17
+ """取记住的密码;无条目或凭据库不可用返回 None。"""
18
+ try:
19
+ import keyring
20
+
21
+ return keyring.get_password(_SERVICE, display_name(host, user, port))
22
+ except Exception: # noqa: BLE001 - 凭据库缺席/被锁只降级,不打挂连接
23
+ return None
24
+
25
+
26
+ def load_password_alias(host: str, user: str | None, port: int | None,
27
+ load=None) -> str | None:
28
+ """读取记住的密码:无端口与 ssh 默认端口 22 视同同一键(别名互查)。
29
+
30
+ 凭据键 display_name 在端口非空时附 :port——同一密码可能落在
31
+ ``user@host`` 或 ``user@host:22`` 任一键下(档案流按 target.port=22 存、
32
+ 嵌套 ssh 无 -p 解析出 port=None 来查),单键直查永远 miss(真机:档案
33
+ 登录成功过、嵌套 ssh 仍弹窗重讨)。先查本键,miss 且 port∈(None, 22)
34
+ 再查另一键;非默认端口(2222 等)不互查。首跳(_connect)与嵌套
35
+ (_load_nested_password)共用此实现,消灭两处漂移。只读——写入仍按
36
+ 调用方给的原样键(不隐式写双键,删除路径才不会删一漏一)。
37
+
38
+ load:读函数注入点(默认本模块 load_password)——core 侧把模块全局
39
+ load_password 传进来,测试 monkeypatch(wmod 桥)语义不变。
40
+ """
41
+ get = load or load_password
42
+ pw = get(host, user, port)
43
+ if pw is not None or port not in (None, 22):
44
+ return pw
45
+ return get(host, user, 22 if port is None else None)
46
+
47
+
48
+ def store_password(host: str, user: str | None, port: int | None,
49
+ password: str) -> bool:
50
+ """保存密码;凭据库不可用返回 False(调用方降级,不视为错误)。"""
51
+ try:
52
+ import keyring
53
+
54
+ keyring.set_password(_SERVICE, display_name(host, user, port), password)
55
+ return True
56
+ except Exception: # noqa: BLE001
57
+ return False
58
+
59
+
60
+ def delete_password(host: str, user: str | None, port: int | None) -> bool:
61
+ """删除记住的密码(删除/编辑连接时清理旧凭据);不存在或不可用返回 False。"""
62
+ try:
63
+ import keyring
64
+
65
+ keyring.delete_password(_SERVICE, display_name(host, user, port))
66
+ return True
67
+ except Exception: # noqa: BLE001
68
+ return False
@@ -0,0 +1,6 @@
1
+ """旧路径壳(阶段二 Task 11):实现已迁至 connections/db.py。"""
2
+
3
+ from .connections.db import ( # noqa: F401
4
+ RenameConflictError, connect, db_path, delete_saved, fetch_saved,
5
+ rename_saved, replace_saved, upsert_saved,
6
+ )
@@ -0,0 +1,41 @@
1
+ """openterminal.core——编排层子包(阶段一终态)。
2
+
3
+ PipelineCore 组合根居 pipeline.py;组件(auth/exec_events/hook_gate/
4
+ cmdset_runner/ai_flow/pump)、消息协议(messages)、嵌套跟踪(nested_ssh)
5
+ 平铺本包。此处显式再导出公开 API;未列出的符号(build_agent 占位、
6
+ SUBMIT_FALLBACK_DELAY、_PW_*、load_password 等历史 monkeypatch/直读目标)
7
+ 经 PEP 562 回退转发 pipeline 模块。_CoreBridge 把对包属性的赋值同步转发
8
+ pipeline——web/worker.py 的 _PatchBridge 经此二跳落到实现模块(测试
9
+ monkeypatch wmod.X 的实现端可见性,阶段一验收硬线)。
10
+ """
11
+
12
+ import sys as _sys
13
+
14
+ from .messages import (
15
+ CHANNEL_DEAD_EC, CHANNEL_DEAD_MSG, ClientMsg, CommandResult,
16
+ InteractiveRunner, PasswordCancelled, ServerMsg,
17
+ )
18
+ from .pipeline import CorePresenter, PipelineCore
19
+
20
+
21
+ def __getattr__(name):
22
+ # 回退转发:实现全局现居 pipeline 模块
23
+ from . import pipeline as _impl
24
+ try:
25
+ return getattr(_impl, name)
26
+ except AttributeError:
27
+ raise AttributeError(f"module {__name__!r} has no attribute {name!r}")
28
+
29
+
30
+ class _CoreBridge(type(_sys.modules[__name__])):
31
+ """补丁桥:对包属性的赋值同步转发到实现模块 pipeline。"""
32
+
33
+ def __setattr__(cls, name, value):
34
+ super().__setattr__(name, value)
35
+ if not name.startswith("__"):
36
+ from . import pipeline as _impl
37
+ if hasattr(_impl, name):
38
+ setattr(_impl, name, value)
39
+
40
+
41
+ _sys.modules[__name__].__class__ = _CoreBridge