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
openterminal/cli.py ADDED
@@ -0,0 +1,426 @@
1
+ """CLI 前端:主菜单 / 主机管理 + 单管线终端(term_frontend)。
2
+
3
+ 选定连接目标后进入 TermFrontend 单管线终端:输入即终端(PTY 直通),
4
+ 自然语言直接交给 AI,/help 查看命令(/target /system /clear /model /exit
5
+ 等斜杠命令由核心 core.py 统一承接)。
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.application import Application
13
+ from prompt_toolkit.key_binding import KeyBindings
14
+ from prompt_toolkit.layout import Layout, Window
15
+ from prompt_toolkit.layout.controls import FormattedTextControl
16
+ from prompt_toolkit.layout.dimension import D
17
+ from prompt_toolkit.styles import Style
18
+
19
+ from .frontend import render
20
+ from .config import Config, TargetConfig
21
+ from .connections import (
22
+ RenameConflictError, TargetList, build_target_list, cred_alias_eq,
23
+ credential_referenced, delete_saved_target,
24
+ display_name, load_saved_targets, parse_user_at_host,
25
+ rename_saved_target, upsert_saved_target,
26
+ )
27
+ from .policy import Policy
28
+ from .connections.secrets import delete_password, store_password
29
+ from .frontend.term import CliCore, TermFrontend
30
+
31
+ # 全屏/会话接管不再需要 CLI 白名单:单管线前端是整条真终端直通
32
+ # (PTY 字节原样进出),vim/top/tmux 等全屏程序天然原生,零登记。
33
+
34
+
35
+ _ADD_NEW = "__add_new__" # 管理主机里「添加主机」的哨兵键
36
+ _BACK = "__back__" # 各菜单「返回」项(避免与用户主机名撞 key)
37
+
38
+
39
+ def picker_rows(tl: TargetList) -> list[tuple[str | None, str]]:
40
+ """连接目标列表:(key, label);key 为 None 的行只展示不参与选择。
41
+
42
+ 添加/编辑/删除归「管理主机」,这里只列可连目标。
43
+ """
44
+ rows: list[tuple[str | None, str]] = [("local", "local(本机终端)")]
45
+ if tl.direct:
46
+ rows.append((None, "── 直接连接 ──"))
47
+ rows.extend((t.name, t.name) for t in tl.direct)
48
+ rows.append((_BACK, "返回"))
49
+ return rows
50
+
51
+
52
+
53
+
54
+ # --- 光标选择菜单(prompt_toolkit Application,替换编号输入)---
55
+
56
+
57
+ def menu_fragments(title: str, rows: list[tuple[str | None, str]],
58
+ selected: int) -> list[tuple[str, str]]:
59
+ """菜单渲染片段(纯函数,可单测):标题 + 列表;selected 指向的可选项
60
+ 反显 + "▸ " 前缀,key=None 的分隔行只展示不参与选择。"""
61
+ out: list[tuple[str, str]] = [("bold", f" {title}")]
62
+ si = 0
63
+ for k, label in rows:
64
+ out.append(("", "\n"))
65
+ if k is None:
66
+ out.append(("class:sep", f" ── {label} ──"))
67
+ continue
68
+ if si == selected:
69
+ out.append(("class:menu-selected", f"▸ {label}"))
70
+ else:
71
+ out.append(("", f" {label}"))
72
+ si += 1
73
+ return out
74
+
75
+
76
+ def pick_menu_sync(title: str, rows: list[tuple[str | None, str]], *,
77
+ input=None, output=None) -> str | None:
78
+ """光标菜单:↑/↓ 或 j/k 移动高亮,回车返回选中 key,q/Esc/Ctrl-C/D 返回 None。
79
+
80
+ rows 为 (key, label);key 为 None 的行只展示(分隔/占位)不参与选择。
81
+ input/output 供测试注入 create_pipe_input / 捕获输出。
82
+ """
83
+ selectable = [(k, label) for k, label in rows if k is not None]
84
+ if not selectable:
85
+ return None
86
+ idx = 0
87
+
88
+ def get_fragments():
89
+ return menu_fragments(title, rows, idx)
90
+
91
+ kb = KeyBindings()
92
+
93
+ @kb.add("down")
94
+ @kb.add("j")
95
+ def _down(event): # noqa: ANN202
96
+ nonlocal idx
97
+ idx = (idx + 1) % len(selectable)
98
+
99
+ @kb.add("up")
100
+ @kb.add("k")
101
+ def _up(event): # noqa: ANN202
102
+ nonlocal idx
103
+ idx = (idx - 1) % len(selectable)
104
+
105
+ @kb.add("enter")
106
+ def _ok(event): # noqa: ANN202
107
+ event.app.exit(result=selectable[idx][0])
108
+
109
+ @kb.add("q")
110
+ @kb.add("escape")
111
+ @kb.add("c-c")
112
+ @kb.add("c-d")
113
+ def _cancel(event): # noqa: ANN202
114
+ event.app.exit(result=None)
115
+
116
+ app = Application(
117
+ layout=Layout(Window(
118
+ FormattedTextControl(get_fragments),
119
+ height=D(preferred=len(rows) + 1),
120
+ )),
121
+ key_bindings=kb,
122
+ style=Style.from_dict({
123
+ "sep": "dim",
124
+ "menu-selected": "reverse",
125
+ }),
126
+ input=input,
127
+ output=output,
128
+ full_screen=False,
129
+ erase_when_done=True,
130
+ )
131
+ try:
132
+ return app.run()
133
+ except EOFError:
134
+ # 输入流结束(管道/重定向,如 `ot < /dev/null`)→ 视为取消
135
+ return None
136
+
137
+
138
+ async def pick_menu(title: str, rows: list[tuple[str | None, str]]) -> str | None:
139
+ """光标选择一屏条目;返回选中 key 或 None(取消)。"""
140
+ loop = asyncio.get_running_loop()
141
+ return await loop.run_in_executor(None, lambda: pick_menu_sync(title, rows))
142
+
143
+
144
+ def _host_label(t: TargetConfig) -> str:
145
+ return display_name(t.host or t.name, t.user, t.port)
146
+
147
+
148
+ class Cli:
149
+ def __init__(self, cfg: Config, initial_target: str | None = None) -> None:
150
+ self.cfg = cfg
151
+ self.initial_target = initial_target
152
+ self.policy = Policy(
153
+ mode=cfg.policy.mode, auto_extra=cfg.policy.auto_extra,
154
+ approve_extra=cfg.policy.approve_extra, deny_extra=cfg.policy.deny_extra,
155
+ )
156
+ # 本次会话里由用户手敲 user@host 临时注册的目标(连接成功后询问是否记住)
157
+ self._adhoc_targets: set[str] = set()
158
+ # config.toml 手写目标的名字(含 default):只读,管理主机时不许被覆盖
159
+ self._config_target_names = set(self.cfg.targets)
160
+ # 记住的连接注册进可选目标(config.toml 里手写的优先)
161
+ for t in load_saved_targets():
162
+ self.cfg.targets.setdefault(t.name, t)
163
+
164
+ def _target_name_for(self, text: str) -> str:
165
+ """把 'prod-web' 或 'deploy@host:2222' 注册成 TargetConfig,返回名字。"""
166
+ host, user, port = parse_user_at_host(text)
167
+ name = text if user is None and port is None else host
168
+ if name not in self.cfg.targets:
169
+ self.cfg.targets[name] = TargetConfig(
170
+ name=name, mode="ssh", host=host, user=user, port=port,
171
+ )
172
+ self._adhoc_targets.add(name)
173
+ return name
174
+
175
+ async def _maybe_remember(self, name: str, core=None) -> None:
176
+ """手敲的 user@host 连接成功后询问是否记住。
177
+
178
+ 元数据存 ~/.openterminal/connections.db、密码走系统凭据库,都不入
179
+ git。core 为本次运行的 CliCore——单管线下会话挂在核心上,实际使用
180
+ 的密码从 core.session.last_password 读;连接未成功(connected 未置位
181
+ 或 core 缺失)不打扰用户,adhoc 名字保留、下次成功连接后可再询问。
182
+ """
183
+ if name not in self._adhoc_targets:
184
+ return
185
+ if core is None or not core.connected.is_set():
186
+ return
187
+ self._adhoc_targets.discard(name)
188
+ target = self.cfg.targets.get(name)
189
+ if not target or target.mode != "ssh" or not target.host:
190
+ return
191
+ saved = load_saved_targets()
192
+ if any(t.host == target.host and t.user == target.user
193
+ and t.port == target.port for t in saved):
194
+ return
195
+ try:
196
+ ans = (await _prompt_text(
197
+ "记住该连接(含密码,存系统凭据库)?下次启动可直接选择 [y/N]: "
198
+ )).strip().lower()
199
+ except EOFError:
200
+ return
201
+ if ans not in {"y", "yes"}:
202
+ return
203
+ disp = display_name(target.host, target.user, target.port)
204
+ upsert_saved_target(name=disp, host=target.host,
205
+ port=target.port, user=target.user)
206
+ # 密码走系统凭据库(Windows 凭据管理器 / macOS Keychain / Linux
207
+ # Secret Service),明文不落库;本连接未用到密码(密钥认证)或
208
+ # 凭据库不可用时跳过
209
+ pw = getattr(getattr(core, "session", None), "last_password", None)
210
+ note = ""
211
+ if pw is not None:
212
+ if store_password(target.host, target.user, target.port, pw):
213
+ note = ",密码已存入系统凭据库"
214
+ else:
215
+ note = "(本机无可用凭据库,密码未存,下次仍需输入)"
216
+ render.console.print(
217
+ f"[green]已记住 {disp}[/] "
218
+ f"[dim](~/.openterminal/connections.db{note})[/]")
219
+
220
+ async def _pick_startup_target(self) -> str | None:
221
+ """连接目标选择(光标菜单):local · 直接连接。
222
+ 添加/编辑/删除在「管理主机」;取消或「返回」返回 None(回主菜单)。"""
223
+ while True:
224
+ rows = picker_rows(build_target_list())
225
+ pick = await pick_menu("选择连接目标:", rows)
226
+ if pick is None or pick == _BACK:
227
+ return None # 取消或「返回」→ 回主菜单
228
+ if pick == "local":
229
+ return "default"
230
+ return pick # 记住的直接连接
231
+
232
+ def _sync_saved_targets(self) -> None:
233
+ """按 connections.toml 重建 cfg.targets 里「记住的连接」条目;
234
+ config.toml 手写目标(_config_target_names)保持优先且只读。"""
235
+ fresh = load_saved_targets()
236
+ fresh_names = {t.name for t in fresh}
237
+ self.cfg.targets.update(
238
+ {t.name: t for t in fresh if t.name not in self._config_target_names})
239
+ for name in list(self.cfg.targets):
240
+ if (name not in self._config_target_names
241
+ and name not in fresh_names
242
+ and name not in self._adhoc_targets):
243
+ del self.cfg.targets[name]
244
+
245
+ async def _prompt_add_host(self) -> str | None:
246
+ """「管理主机」里添加主机:手敲 user@host[:port],立即存进 connections.toml。
247
+
248
+ 不注册临时目标、不连接,直接落盘;按 host/user/port 去重;留空或取消返回 None。
249
+ """
250
+ try:
251
+ text = (await _prompt_text(
252
+ "输入连接(user@host[:端口],如 root@1.2.3.4:2222): ")).strip()
253
+ except EOFError:
254
+ render.console.print("[yellow]输入结束,返回管理页。[/]")
255
+ return None
256
+ if not text:
257
+ return None
258
+ host, user, port = parse_user_at_host(text)
259
+ saved = load_saved_targets()
260
+ if any(t.host == host and t.user == user and t.port == port
261
+ for t in saved):
262
+ render.console.print(
263
+ f"[yellow]主机 {display_name(host, user, port)} 已存在。[/]")
264
+ return None
265
+ name = display_name(host, user, port)
266
+ upsert_saved_target(name=name, host=host, port=port, user=user)
267
+ self._sync_saved_targets()
268
+ render.console.print(f"[green]已添加主机 {name}[/]")
269
+ return name
270
+
271
+ async def _edit_saved_host(self, name: str) -> bool:
272
+ """编辑已存主机:重敲 user@host[:port],单事务替换存储条目并同步
273
+ cfg.targets;地址变了顺手清旧 keyring 凭据(不留孤儿密码)。
274
+ 留空/取消/撞名返回 False。"""
275
+ saved = load_saved_targets()
276
+ target = next((t for t in saved if t.name == name), None)
277
+ if target is None:
278
+ return False
279
+ old_label = _host_label(target)
280
+ try:
281
+ text = (await _prompt_text(
282
+ f"新连接(当前 {old_label},user@host[:端口]): ")).strip()
283
+ except EOFError:
284
+ render.console.print("[yellow]输入结束,取消编辑。[/]")
285
+ return False
286
+ if not text:
287
+ return False
288
+ host, user, port = parse_user_at_host(text)
289
+ new_label = display_name(host, user, port)
290
+ if any(t.host == host and t.user == user and t.port == port
291
+ and t.name != target.name for t in saved):
292
+ render.console.print(f"[yellow]主机 {new_label} 已存在。[/]")
293
+ return False
294
+ try:
295
+ removed = rename_saved_target(name, name=new_label, host=host,
296
+ port=port, user=user)
297
+ except RenameConflictError:
298
+ render.console.print(f"[yellow]名称 {new_label} 已被另一条连接占用。[/]")
299
+ return False
300
+ if removed is not None and not cred_alias_eq(
301
+ (removed.host, removed.user, removed.port), (host, user, port)):
302
+ # 地址变了(port None/22 视同没变):清旧凭据,但仅当无其他
303
+ # 条目共享(与 Web 端 edit_saved 同规则——共享键防误删 §2-6)
304
+ if not credential_referenced(removed.host, removed.user,
305
+ removed.port):
306
+ delete_password(removed.host, removed.user, removed.port)
307
+ self._sync_saved_targets()
308
+ render.console.print(f"[green]已更新主机 {old_label} → {new_label}[/]")
309
+ return True
310
+
311
+ async def _delete_saved_host(self, name: str) -> None:
312
+ """删除已存主机,并同步清掉它的 keyring 凭据(不留孤儿密码;
313
+ 其他条目仍共享该凭据时不动,与 Web 端 remove_saved 同规则)。"""
314
+ removed = delete_saved_target(name)
315
+ if removed is not None and not credential_referenced(
316
+ removed.host, removed.user, removed.port):
317
+ delete_password(removed.host, removed.user, removed.port)
318
+ self._sync_saved_targets()
319
+
320
+ async def _manage_saved_host(self, name: str) -> None:
321
+ """单个已存主机:编辑 / 删除 / 返回(按名字定位,不受列表变动影响)。"""
322
+ target = next((t for t in load_saved_targets() if t.name == name), None)
323
+ if target is None:
324
+ return
325
+ label = _host_label(target)
326
+ act = await pick_menu(f"主机 {label}", [
327
+ ("edit", "编辑"),
328
+ ("delete", "删除"),
329
+ (_BACK, "返回"),
330
+ ])
331
+ if act == "edit":
332
+ await self._edit_saved_host(name)
333
+ elif act == "delete":
334
+ try:
335
+ ans = (await _prompt_text(
336
+ f"删除该主机 {label}?[y/N]: ")).strip().lower()
337
+ except EOFError:
338
+ ans = ""
339
+ if ans in {"y", "yes"}:
340
+ await self._delete_saved_host(name)
341
+ render.console.print(f"[green]已删除主机 {label}[/]")
342
+
343
+ async def _manage_hosts(self) -> None:
344
+ """管理主机:列出已存主机,支持添加/编辑/删除(光标菜单)。"""
345
+ while True:
346
+ saved = load_saved_targets()
347
+ rows: list[tuple[str | None, str]] = [(None, "主机")]
348
+ if saved:
349
+ # 行键用主机名而非下标:菜单停留期间库被别处(Web/另一进程)
350
+ # 增删时,下标会错位指到别人的条目
351
+ rows.extend((("h", t.name), _host_label(t)) for t in saved)
352
+ else:
353
+ rows.append((None, "(还没有主机)"))
354
+ rows.append((_ADD_NEW, "添加主机"))
355
+ rows.append((_BACK, "返回"))
356
+
357
+ pick = await pick_menu("管理主机", rows)
358
+ if pick is None or pick == _BACK:
359
+ return
360
+ if pick == _ADD_NEW:
361
+ await self._prompt_add_host()
362
+ continue
363
+ if isinstance(pick, tuple) and pick[0] == "h":
364
+ await self._manage_saved_host(pick[1])
365
+ continue
366
+
367
+ async def _connect_via_picker(self) -> str | None:
368
+ """经选择框选连接目标,返回目标名(连接由 _run_pipeline 进行,此处
369
+ 不再开 session);取消或「返回」返回 None。连接失败时核心发 closed、
370
+ _run_pipeline 返回后回主菜单,可重选目标。
371
+
372
+ 「是否记住手敲连接」的询问在 _run_pipeline 里、连接真正成功之后
373
+ (_maybe_remember 需要 core.connected / core.session,选完目标时
374
+ 连接还没发生)。"""
375
+ name = await self._pick_startup_target()
376
+ if name is None:
377
+ return None
378
+ self._sync_saved_targets()
379
+ return name
380
+
381
+ async def loop(self) -> None:
382
+ if self.initial_target:
383
+ first = self._target_name_for(self.initial_target)
384
+ await self._run_pipeline(first)
385
+ # 主菜单:连接主机 / 管理主机 / 退出(光标选择);连接结束后回菜单
386
+ while True:
387
+ pick = await pick_menu("OpenTerminal", [
388
+ ("connect", "连接主机"),
389
+ ("manage", "管理主机"),
390
+ ("exit", "退出"),
391
+ ])
392
+ if pick == "connect":
393
+ name = await self._connect_via_picker()
394
+ if name is not None:
395
+ await self._run_pipeline(name)
396
+ elif pick == "manage":
397
+ await self._manage_hosts()
398
+ else: # exit 或取消
399
+ return
400
+
401
+ async def _run_pipeline(self, name: str) -> None:
402
+ """以目标名起单管线终端:CliCore(核心)+ TermFrontend(raw 输入 +
403
+ 内联渲染 + 本地截获)。输入即终端,自然语言直接交给 AI。
404
+
405
+ run() 返回(核心 closed / EOF / 异常)即回主菜单;之后必须补
406
+ core.close()——EOF/异常/连接失败路径下 run() 返回时核心未关
407
+ (Task 3 前向契约),CliCore.close() 幂等。try/finally 保证 run()
408
+ 抛异常(未来 loop() 若在菜单里兜住异常)时核心也被关闭。
409
+
410
+ 管线结束后询问「是否记住手敲连接」——此时终端已复原,且能按
411
+ core.connected 判断连接是否真的成功过(失败不打扰用户)。"""
412
+ core = CliCore(self.cfg, name, frontend=None) # 两段构造:反向注入
413
+ frontend = TermFrontend(core)
414
+ core._frontend = frontend
415
+ try:
416
+ await frontend.run()
417
+ finally:
418
+ await core.close()
419
+ await self._maybe_remember(name, core)
420
+
421
+
422
+ async def _prompt_text(text: str, default: str = "") -> str:
423
+ session = PromptSession()
424
+ loop = asyncio.get_running_loop()
425
+ return await loop.run_in_executor(
426
+ None, lambda: session.prompt(text, default=default))
openterminal/cmdset.py ADDED
@@ -0,0 +1,7 @@
1
+ """旧路径壳(阶段二 Task 13):实现已迁至 shell/cmdset.py。"""
2
+
3
+ from .shell.cmdset import * # noqa: F401,F403
4
+ from .shell import cmdset as _impl # noqa: F401 —— 便于测试直引私有名
5
+
6
+ def __getattr__(name):
7
+ return getattr(_impl, name)