lithe-cli 0.2.4__tar.gz → 0.4.0__tar.gz
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.
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/PKG-INFO +13 -5
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/README.md +10 -3
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/agent.py +14 -5
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/config.py +2 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/main.py +15 -8
- lithe_cli-0.4.0/lithe_cli/prompts.py +118 -0
- lithe_cli-0.4.0/lithe_cli/setup.py +109 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/ui.py +49 -21
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli.egg-info/PKG-INFO +13 -5
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli.egg-info/SOURCES.txt +1 -1
- lithe_cli-0.4.0/lithe_cli.egg-info/requires.txt +6 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/pyproject.toml +1 -1
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/tests/test_cli.py +133 -126
- lithe_cli-0.2.4/lithe_cli/setup.py +0 -198
- lithe_cli-0.2.4/lithe_cli/ttyio.py +0 -195
- lithe_cli-0.2.4/lithe_cli.egg-info/requires.txt +0 -5
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/LICENSE +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.4.0}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.0
|
|
4
4
|
Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
|
|
5
5
|
License: MIT License
|
|
6
6
|
|
|
@@ -39,7 +39,8 @@ Classifier: Typing :: Typed
|
|
|
39
39
|
Requires-Python: >=3.10
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: lithe>=0.9.
|
|
42
|
+
Requires-Dist: lithe>=0.9.7
|
|
43
|
+
Requires-Dist: prompt-toolkit>=3.0.38
|
|
43
44
|
Provides-Extra: dev
|
|
44
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
45
46
|
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
@@ -117,7 +118,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
117
118
|
```bash
|
|
118
119
|
$ lithe chat
|
|
119
120
|
╭──────────────────────────────────────╮
|
|
120
|
-
│ lithe 0.
|
|
121
|
+
│ lithe 0.4.0 │
|
|
121
122
|
│ model your-model │
|
|
122
123
|
│ workdir /home/me/my-project │
|
|
123
124
|
│ /help 查看会话命令,/exit 退出 │
|
|
@@ -141,6 +142,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
141
142
|
| Flag | Tools granted | Notes |
|
|
142
143
|
| --- | --- | --- |
|
|
143
144
|
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
145
|
+
| `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
|
|
144
146
|
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
145
147
|
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
146
148
|
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
@@ -148,6 +150,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
148
150
|
|
|
149
151
|
```bash
|
|
150
152
|
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
153
|
+
$ lithe run --shell "检查当前目录的项目状态"
|
|
151
154
|
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
152
155
|
```
|
|
153
156
|
|
|
@@ -197,8 +200,12 @@ them only after a session has started the servers.)
|
|
|
197
200
|
### Colors
|
|
198
201
|
|
|
199
202
|
Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
|
|
200
|
-
/ `--no-color` force either way.
|
|
201
|
-
text degrades cleanly through pipes and cron.
|
|
203
|
+
/ `--no-color` force either way. Output rendering itself stays
|
|
204
|
+
dependency-free — plain text degrades cleanly through pipes and cron.
|
|
205
|
+
Interactive *input* (the chat prompt and the setup wizard) is
|
|
206
|
+
[prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
|
|
207
|
+
wide-character-safe editing, bracketed-paste handling, star-echoed API
|
|
208
|
+
keys, inline wizard validation, and persistent history.
|
|
202
209
|
|
|
203
210
|
## Where things land
|
|
204
211
|
|
|
@@ -208,6 +215,7 @@ text degrades cleanly through pipes and cron.
|
|
|
208
215
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
209
216
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
210
217
|
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
218
|
+
| `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
|
|
211
219
|
|
|
212
220
|
## Design notes
|
|
213
221
|
|
|
@@ -70,7 +70,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
70
70
|
```bash
|
|
71
71
|
$ lithe chat
|
|
72
72
|
╭──────────────────────────────────────╮
|
|
73
|
-
│ lithe 0.
|
|
73
|
+
│ lithe 0.4.0 │
|
|
74
74
|
│ model your-model │
|
|
75
75
|
│ workdir /home/me/my-project │
|
|
76
76
|
│ /help 查看会话命令,/exit 退出 │
|
|
@@ -94,6 +94,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
94
94
|
| Flag | Tools granted | Notes |
|
|
95
95
|
| --- | --- | --- |
|
|
96
96
|
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
97
|
+
| `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
|
|
97
98
|
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
98
99
|
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
99
100
|
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
@@ -101,6 +102,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
101
102
|
|
|
102
103
|
```bash
|
|
103
104
|
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
105
|
+
$ lithe run --shell "检查当前目录的项目状态"
|
|
104
106
|
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
105
107
|
```
|
|
106
108
|
|
|
@@ -150,8 +152,12 @@ them only after a session has started the servers.)
|
|
|
150
152
|
### Colors
|
|
151
153
|
|
|
152
154
|
Output is colored when stdout is a TTY and `NO_COLOR` is unset; `--color`
|
|
153
|
-
/ `--no-color` force either way.
|
|
154
|
-
text degrades cleanly through pipes and cron.
|
|
155
|
+
/ `--no-color` force either way. Output rendering itself stays
|
|
156
|
+
dependency-free — plain text degrades cleanly through pipes and cron.
|
|
157
|
+
Interactive *input* (the chat prompt and the setup wizard) is
|
|
158
|
+
[prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
|
|
159
|
+
wide-character-safe editing, bracketed-paste handling, star-echoed API
|
|
160
|
+
keys, inline wizard validation, and persistent history.
|
|
155
161
|
|
|
156
162
|
## Where things land
|
|
157
163
|
|
|
@@ -161,6 +167,7 @@ text degrades cleanly through pipes and cron.
|
|
|
161
167
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
162
168
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
163
169
|
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
170
|
+
| `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
|
|
164
171
|
|
|
165
172
|
## Design notes
|
|
166
173
|
|
|
@@ -8,9 +8,9 @@ Everything here maps lithe's host contract onto CLI defaults:
|
|
|
8
8
|
kernel's zero-database default;
|
|
9
9
|
- tools — the workspace bundle (read/write/edit/list/search/glob +
|
|
10
10
|
apply_patch) and todos, plus opt-in capabilities: ``--code`` (sandboxed
|
|
11
|
-
Python), ``--
|
|
12
|
-
probe/analysis), ``--download`` (SSRF-guarded
|
|
13
|
-
``--mcp`` (external MCP servers);
|
|
11
|
+
Python), ``--shell`` (native host commands), ``--skills`` (markdown skill
|
|
12
|
+
library), ``--vision`` (image probe/analysis), ``--download`` (SSRF-guarded
|
|
13
|
+
network fetch) and ``--mcp`` (external MCP servers);
|
|
14
14
|
- undo — the bundled tools register reverters, so ``lithe undo`` works with
|
|
15
15
|
zero configuration.
|
|
16
16
|
"""
|
|
@@ -32,6 +32,7 @@ from lithe.bundles import (
|
|
|
32
32
|
Workspace,
|
|
33
33
|
undo_run,
|
|
34
34
|
)
|
|
35
|
+
from lithe.bundles.command import register_command_tools
|
|
35
36
|
from lithe.bundles.download import register_download_tools
|
|
36
37
|
from lithe.bundles.patch import register_apply_patch_tool
|
|
37
38
|
from lithe.bundles.workspace import register_file_tools
|
|
@@ -57,6 +58,8 @@ def build_system_prompt(cfg: Config) -> str:
|
|
|
57
58
|
extras = []
|
|
58
59
|
if cfg.code:
|
|
59
60
|
extras.append("可以用 run_code 执行 Python 来验证想法、计算或测试。")
|
|
61
|
+
if cfg.shell:
|
|
62
|
+
extras.append("可以用 run_command 在主机上运行系统命令;命令有当前用户权限且副作用不可撤销,执行前先确认必要性。")
|
|
60
63
|
if cfg.download:
|
|
61
64
|
extras.append("可以用 download_file 下载网络文件到工作区。")
|
|
62
65
|
if cfg.skills_dir is not None:
|
|
@@ -97,6 +100,12 @@ def build_registry(cfg: Config) -> ToolRegistry:
|
|
|
97
100
|
|
|
98
101
|
runner = CodeRunner(sys.executable, backend=sandbox_backend())
|
|
99
102
|
register_code_tools(reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
|
|
103
|
+
if cfg.shell:
|
|
104
|
+
from lithe.bundles.command import CommandRunner
|
|
105
|
+
|
|
106
|
+
register_command_tools(
|
|
107
|
+
reg, lambda ctx: cfg.workspace_dir.resolve(), CommandRunner()
|
|
108
|
+
)
|
|
100
109
|
if cfg.skills_dir is not None:
|
|
101
110
|
from lithe.bundles.skills import SkillLibrary, register_skill_tool
|
|
102
111
|
|
|
@@ -146,14 +155,14 @@ def render_event(
|
|
|
146
155
|
ui.assistant(text)
|
|
147
156
|
elif t == "tool_call":
|
|
148
157
|
args = json.dumps(ev.get("args", {}), ensure_ascii=False)
|
|
149
|
-
ui.tool_call(str(ev.get("name")), args
|
|
158
|
+
ui.tool_call(str(ev.get("name")), args)
|
|
150
159
|
if ev.get("id") is not None:
|
|
151
160
|
pending[ev["id"]] = time.monotonic()
|
|
152
161
|
elif t == "tool_result":
|
|
153
162
|
summary = (ev.get("summary") or "").replace("\n", " ")
|
|
154
163
|
started = pending.pop(ev.get("id"), None) if ev.get("id") else None
|
|
155
164
|
elapsed = (time.monotonic() - started) if started is not None else None
|
|
156
|
-
ui.tool_result(bool(ev.get("ok")), summary
|
|
165
|
+
ui.tool_result(bool(ev.get("ok")), summary, elapsed)
|
|
157
166
|
elif t == "error":
|
|
158
167
|
ui.error(str(ev.get("message", "")))
|
|
159
168
|
elif t == "cancelled":
|
|
@@ -57,6 +57,7 @@ class Config:
|
|
|
57
57
|
download: bool = False
|
|
58
58
|
verbose: bool = False
|
|
59
59
|
code: bool = False
|
|
60
|
+
shell: bool = False
|
|
60
61
|
skills_dir: Path | None = None
|
|
61
62
|
mcp_spec: str | None = None
|
|
62
63
|
mcp_servers: list = field(default_factory=list)
|
|
@@ -204,6 +205,7 @@ def load_config(args: Any) -> Config:
|
|
|
204
205
|
download=g("download", False),
|
|
205
206
|
verbose=g("verbose", False),
|
|
206
207
|
code=g("code", False),
|
|
208
|
+
shell=g("shell", False),
|
|
207
209
|
skills_dir=skills_dir,
|
|
208
210
|
mcp_spec=mcp_raw,
|
|
209
211
|
mcp_servers=load_mcp_spec(mcp_raw) if mcp_raw else [],
|
|
@@ -29,9 +29,10 @@ from lithe import __version__ as lithe_version
|
|
|
29
29
|
from lithe.bundles import JsonlRunStore
|
|
30
30
|
|
|
31
31
|
from . import __version__
|
|
32
|
+
from . import prompts
|
|
32
33
|
from .agent import build_registry, execute, sandbox_backend, undo
|
|
33
34
|
from .config import config_file, load_config, require_endpoint
|
|
34
|
-
from . import
|
|
35
|
+
from .prompts import open_history
|
|
35
36
|
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
|
|
36
37
|
|
|
37
38
|
|
|
@@ -65,6 +66,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
65
66
|
help="grant run_code/run_file (sandboxed Python; "
|
|
66
67
|
"bwrap when available, passthrough otherwise)",
|
|
67
68
|
)
|
|
69
|
+
sp.add_argument(
|
|
70
|
+
"--shell",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="grant run_command (native shell with current-user host access)",
|
|
73
|
+
)
|
|
68
74
|
sp.add_argument(
|
|
69
75
|
"--skills",
|
|
70
76
|
metavar="DIR",
|
|
@@ -177,7 +183,7 @@ def _cmd_runs(cfg: Any, limit: int) -> int:
|
|
|
177
183
|
rows = []
|
|
178
184
|
for r in reversed(runs): # newest last, like a log
|
|
179
185
|
task = (r.task or "").replace("\n", " ")
|
|
180
|
-
rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", task
|
|
186
|
+
rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", truncate(task, 60)])
|
|
181
187
|
print(
|
|
182
188
|
ui.table(
|
|
183
189
|
["run", "status", "steps", "cost", "task"],
|
|
@@ -223,7 +229,7 @@ def _cmd_doctor() -> int:
|
|
|
223
229
|
class _Args: # doctor reuses load_config over env-only defaults
|
|
224
230
|
api_key = base_url = model = store = workspace = user = None
|
|
225
231
|
skills = mcp = None
|
|
226
|
-
download = code = vision = color = no_color = verbose = False
|
|
232
|
+
download = code = shell = vision = color = no_color = verbose = False
|
|
227
233
|
|
|
228
234
|
cfg = load_config(_Args())
|
|
229
235
|
print(ui.s(f"lithe-cli {__version__}(lithe {lithe_version})", CYAN, BOLD))
|
|
@@ -263,6 +269,8 @@ def _cmd_doctor() -> int:
|
|
|
263
269
|
else:
|
|
264
270
|
sandbox_line = ui.s("passthrough(未找到 bwrap,无隔离)", RED)
|
|
265
271
|
print(ui.kv("sandbox", sandbox_line + ";--code 启用 run_code"))
|
|
272
|
+
command_line = "已启用(run_command 可执行主机命令)" if cfg.shell else "未启用(--shell 显式授权)"
|
|
273
|
+
print(ui.kv("shell", command_line))
|
|
266
274
|
|
|
267
275
|
skills = default_skills_dir()
|
|
268
276
|
if cfg.skills_dir is not None:
|
|
@@ -330,13 +338,13 @@ _CHAT_HELP = """可用命令:
|
|
|
330
338
|
def _chat_loop(cfg: Any) -> int:
|
|
331
339
|
store = JsonlRunStore(cfg.store_dir)
|
|
332
340
|
run_ids: list[str] = []
|
|
333
|
-
|
|
341
|
+
input_history = open_history() # FileHistory under $LITHE_HOME, shared runs
|
|
334
342
|
print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
|
|
335
343
|
while True:
|
|
336
344
|
try:
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
line =
|
|
345
|
+
# prompt_toolkit owns the terminal: wide-char safe, paste-safe,
|
|
346
|
+
# persistent history — never input()/readline
|
|
347
|
+
line = prompts.chat_line(ui.prompt(), input_history).strip()
|
|
340
348
|
except (EOFError, KeyboardInterrupt):
|
|
341
349
|
print()
|
|
342
350
|
break
|
|
@@ -351,7 +359,6 @@ def _chat_loop(cfg: Any) -> int:
|
|
|
351
359
|
continue
|
|
352
360
|
if not line:
|
|
353
361
|
continue
|
|
354
|
-
history.append(line)
|
|
355
362
|
if line in ("/exit", "/quit", "exit", "quit"):
|
|
356
363
|
break
|
|
357
364
|
if line == "/help":
|
|
@@ -0,0 +1,118 @@
|
|
|
1
|
+
"""All interactive input goes through prompt_toolkit.
|
|
2
|
+
|
|
3
|
+
One seam for every prompt the CLI shows: the chat line (persistent
|
|
4
|
+
history, ANSI-colored prompt), the wizard's questions (inline
|
|
5
|
+
validation, hidden API key with star echo), and yes/no confirms.
|
|
6
|
+
|
|
7
|
+
prompt_toolkit owns the terminal: it draws its own line editor with
|
|
8
|
+
proper wide-character widths (no readline multibyte corruption), turns
|
|
9
|
+
on and consumes bracketed paste itself, and masks ``is_password``
|
|
10
|
+
input with asterisks — the three failure classes this CLI hit when it
|
|
11
|
+
used ``input``/``getpass``/readline are all upstream concerns now.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from __future__ import annotations
|
|
15
|
+
|
|
16
|
+
from prompt_toolkit import prompt
|
|
17
|
+
from prompt_toolkit.formatted_text import ANSI
|
|
18
|
+
from prompt_toolkit.history import FileHistory, InMemoryHistory
|
|
19
|
+
from prompt_toolkit.validation import ValidationError, Validator
|
|
20
|
+
|
|
21
|
+
from .ui import RED, YELLOW, ui
|
|
22
|
+
|
|
23
|
+
_MAX_EMPTY_TRIES = 3
|
|
24
|
+
|
|
25
|
+
_UTF8_HINT = (
|
|
26
|
+
"\n 这行输入无法按 UTF-8 解码,已丢弃;"
|
|
27
|
+
"请把终端与 locale 设为 UTF-8(如 export LANG=C.UTF-8)后重试。"
|
|
28
|
+
)
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def history_file():
|
|
32
|
+
"""Where chat history persists, next to the saved endpoint."""
|
|
33
|
+
from .config import lithe_home
|
|
34
|
+
|
|
35
|
+
return lithe_home() / "history"
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def open_history() -> FileHistory:
|
|
39
|
+
"""A FileHistory under ``$LITHE_HOME`` (dir created on demand)."""
|
|
40
|
+
path = history_file()
|
|
41
|
+
try:
|
|
42
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
43
|
+
except OSError:
|
|
44
|
+
return InMemoryHistory()
|
|
45
|
+
return FileHistory(str(path))
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
def chat_line(message: str, history) -> str:
|
|
49
|
+
"""The chat REPL prompt: colored, history-searchable, paste-safe."""
|
|
50
|
+
return prompt(
|
|
51
|
+
ANSI(message),
|
|
52
|
+
history=history,
|
|
53
|
+
enable_history_search=True,
|
|
54
|
+
)
|
|
55
|
+
|
|
56
|
+
|
|
57
|
+
class _FnValidator(Validator):
|
|
58
|
+
"""Adapt a ``text -> error string | None`` callable for prompt_toolkit."""
|
|
59
|
+
|
|
60
|
+
def __init__(self, fn):
|
|
61
|
+
self.fn = fn
|
|
62
|
+
|
|
63
|
+
def validate(self, document) -> None:
|
|
64
|
+
problem = self.fn(document.text)
|
|
65
|
+
if problem:
|
|
66
|
+
raise ValidationError(message=problem, cursor_position=len(document.text))
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def ask(
|
|
70
|
+
label: str,
|
|
71
|
+
default: str = "",
|
|
72
|
+
password: bool = False,
|
|
73
|
+
validate=None,
|
|
74
|
+
) -> str | None:
|
|
75
|
+
"""Ask for one value; empty answers keep the default or re-prompt.
|
|
76
|
+
|
|
77
|
+
``validate`` (text -> error string or None) is enforced inline by
|
|
78
|
+
prompt_toolkit — Enter is refused with the message until the value
|
|
79
|
+
passes. Returns None when the user bails out (empty too often,
|
|
80
|
+
Ctrl+C/D).
|
|
81
|
+
"""
|
|
82
|
+
message = f"{label} [回车沿用已保存值]: " if password and default else f"{label}: "
|
|
83
|
+
validator = _FnValidator(validate) if validate is not None else None
|
|
84
|
+
for _ in range(_MAX_EMPTY_TRIES):
|
|
85
|
+
try:
|
|
86
|
+
answer = prompt(
|
|
87
|
+
message,
|
|
88
|
+
default=default if not password else "",
|
|
89
|
+
is_password=password,
|
|
90
|
+
validator=validator,
|
|
91
|
+
).strip()
|
|
92
|
+
except (EOFError, KeyboardInterrupt):
|
|
93
|
+
print()
|
|
94
|
+
return None
|
|
95
|
+
except UnicodeDecodeError:
|
|
96
|
+
print(ui.s(_UTF8_HINT, RED))
|
|
97
|
+
continue
|
|
98
|
+
if not answer and default:
|
|
99
|
+
return default
|
|
100
|
+
if not answer:
|
|
101
|
+
print(ui.s(" 这个值不能为空。", YELLOW))
|
|
102
|
+
continue
|
|
103
|
+
return answer
|
|
104
|
+
return None
|
|
105
|
+
|
|
106
|
+
|
|
107
|
+
def yes_no(label: str, default: bool) -> bool:
|
|
108
|
+
"""A y/n question; empty answer takes the default."""
|
|
109
|
+
hint = "Y/n" if default else "y/N"
|
|
110
|
+
try:
|
|
111
|
+
answer = prompt(f"{label} [{hint}]: ").strip().lower()
|
|
112
|
+
except (EOFError, KeyboardInterrupt):
|
|
113
|
+
print()
|
|
114
|
+
return default
|
|
115
|
+
except UnicodeDecodeError:
|
|
116
|
+
print(ui.s("\n 这行输入无法按 UTF-8 解码,按默认值处理。", RED))
|
|
117
|
+
return default
|
|
118
|
+
return default if not answer else answer in ("y", "yes", "是")
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""First-run endpoint setup: the wizard behind `lithe-cli config`.
|
|
2
|
+
|
|
3
|
+
The CLI still ships no default endpoint — that principle is untouched.
|
|
4
|
+
What changes is the failure mode: on an interactive terminal, a missing
|
|
5
|
+
endpoint means three prompts and a saved config file instead of a
|
|
6
|
+
refusal with a wall of export lines. Non-interactive contexts (pipes,
|
|
7
|
+
scripts, CI) never see the wizard; they keep the hard error.
|
|
8
|
+
|
|
9
|
+
Precedence stays flag > env > config file, so the wizard only fills what
|
|
10
|
+
nothing else provided. All prompting is prompt_toolkit (see
|
|
11
|
+
:mod:`lithe_cli.prompts`): inline URL validation, star-echoed API key,
|
|
12
|
+
paste-safe input.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import os
|
|
18
|
+
import sys
|
|
19
|
+
from urllib.error import HTTPError, URLError
|
|
20
|
+
from urllib.request import Request, urlopen
|
|
21
|
+
|
|
22
|
+
from . import prompts
|
|
23
|
+
from .config import (
|
|
24
|
+
ENV_API_KEY,
|
|
25
|
+
ENV_BASE_URL,
|
|
26
|
+
ENV_MODEL,
|
|
27
|
+
config_file,
|
|
28
|
+
load_saved_endpoint,
|
|
29
|
+
save_endpoint,
|
|
30
|
+
)
|
|
31
|
+
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, ui
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
def stdin_is_interactive() -> bool:
|
|
35
|
+
"""True only when both ends are TTYs — pipes and captures stay clean."""
|
|
36
|
+
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
def _http_like(value: str) -> str | None:
|
|
40
|
+
return (
|
|
41
|
+
None
|
|
42
|
+
if value.startswith(("http://", "https://"))
|
|
43
|
+
else ("Base URL 需以 http:// 或 https:// 开头。")
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
|
|
47
|
+
def probe_endpoint(base_url: str, api_key: str, timeout: float = 10.0) -> bool:
|
|
48
|
+
"""One cheap GET ``{base_url}/models`` to catch typos before chat.
|
|
49
|
+
|
|
50
|
+
The save already happened: a failed probe warns, it never blocks —
|
|
51
|
+
endpoints legitimately lack ``/models`` or auth differently.
|
|
52
|
+
"""
|
|
53
|
+
url = base_url.rstrip("/") + "/models"
|
|
54
|
+
req = Request(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
55
|
+
try:
|
|
56
|
+
with urlopen(req, timeout=timeout) as resp:
|
|
57
|
+
print(ui.s(f"✓ 端点可达(HTTP {resp.status})", GREEN))
|
|
58
|
+
return True
|
|
59
|
+
except HTTPError as exc:
|
|
60
|
+
if exc.code in (401, 403):
|
|
61
|
+
print(ui.s(f"~ 端点可达但拒绝了该 key(HTTP {exc.code})", YELLOW))
|
|
62
|
+
else:
|
|
63
|
+
print(ui.s(f"~ 端点有响应:HTTP {exc.code}(无 /models 也无妨)", YELLOW))
|
|
64
|
+
return True # reachable is all we wanted to know
|
|
65
|
+
except (URLError, OSError, TimeoutError) as exc:
|
|
66
|
+
print(ui.s(f"✗ 连不上 {url}:{exc}", RED))
|
|
67
|
+
return False
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def run_setup_wizard() -> dict | None:
|
|
71
|
+
"""Prompt for base_url / api_key / model and save the config file.
|
|
72
|
+
|
|
73
|
+
Defaults come from the already-saved file, then env vars, so
|
|
74
|
+
re-running the wizard is re-editing, not retyping. Returns the saved
|
|
75
|
+
endpoint dict, or None when the user cancels.
|
|
76
|
+
"""
|
|
77
|
+
if not stdin_is_interactive():
|
|
78
|
+
print(ui.s("交互配置需要终端(stdin/stdout 不是 TTY)。", RED))
|
|
79
|
+
return None
|
|
80
|
+
saved = load_saved_endpoint()
|
|
81
|
+
env = {
|
|
82
|
+
"base_url": os.environ.get(ENV_BASE_URL, "").strip(),
|
|
83
|
+
"api_key": os.environ.get(ENV_API_KEY, "").strip(),
|
|
84
|
+
"model": os.environ.get(ENV_MODEL, "").strip(),
|
|
85
|
+
}
|
|
86
|
+
defaults = {k: saved.get(k, "") or env[k] for k in env}
|
|
87
|
+
|
|
88
|
+
print(ui.s("初始设置:配置模型端点", CYAN, BOLD))
|
|
89
|
+
print(
|
|
90
|
+
f"值保存在 {config_file()}(权限 600);"
|
|
91
|
+
"优先级 flag > 环境变量 > 配置文件。\n"
|
|
92
|
+
"之后可用 `lithe-cli config` 重新配置。"
|
|
93
|
+
)
|
|
94
|
+
base_url = prompts.ask(
|
|
95
|
+
"Base URL(如 https://your-endpoint/api/v1)",
|
|
96
|
+
defaults["base_url"],
|
|
97
|
+
validate=_http_like,
|
|
98
|
+
)
|
|
99
|
+
api_key = prompts.ask("API key", defaults["api_key"], password=True)
|
|
100
|
+
model = prompts.ask("模型名(如 glm-4.6)", defaults["model"])
|
|
101
|
+
if not (base_url and api_key and model):
|
|
102
|
+
print(ui.s("已取消,未保存任何配置。", YELLOW))
|
|
103
|
+
return None
|
|
104
|
+
|
|
105
|
+
path = save_endpoint(api_key, base_url, model)
|
|
106
|
+
print(ui.s(f"✓ 已保存到 {path}", GREEN))
|
|
107
|
+
if prompts.yes_no("现在测试一下连接吗", True):
|
|
108
|
+
probe_endpoint(base_url, api_key)
|
|
109
|
+
return {"api_key": api_key, "base_url": base_url, "model": model}
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
15
|
import re
|
|
16
|
+
import shutil
|
|
16
17
|
import sys
|
|
17
18
|
import unicodedata
|
|
18
19
|
|
|
@@ -81,8 +82,13 @@ def truncate(text: str, width: int) -> str:
|
|
|
81
82
|
class UI:
|
|
82
83
|
"""All terminal output goes through here; ``color`` gates every code."""
|
|
83
84
|
|
|
84
|
-
def __init__(self, color: bool = False):
|
|
85
|
+
def __init__(self, color: bool = False, width: int | None = None):
|
|
85
86
|
self.color = bool(color)
|
|
87
|
+
self._width = width
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def width(self) -> int:
|
|
91
|
+
return max(12, self._width or shutil.get_terminal_size((100, 24)).columns)
|
|
86
92
|
|
|
87
93
|
def s(self, text: str, *styles: str) -> str:
|
|
88
94
|
"""Wrap *text* in *styles* when color is on and text is non-empty."""
|
|
@@ -93,36 +99,53 @@ class UI:
|
|
|
93
99
|
# -- structural ---------------------------------------------------------
|
|
94
100
|
|
|
95
101
|
def rule(self, char: str = "─", width: int = 60) -> str:
|
|
96
|
-
return self.s(char * width, DIM)
|
|
102
|
+
return self.s(char * min(width, self.width), DIM)
|
|
97
103
|
|
|
98
104
|
def kv(self, label: str, value: str) -> str:
|
|
99
105
|
return f"{self.s(pad(label, 11), CYAN, BOLD)} {value}"
|
|
100
106
|
|
|
101
107
|
def section(self, title: str) -> str:
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
width = min(50, self.width)
|
|
109
|
+
label = f"── {title} "
|
|
110
|
+
if display_width(label) > width:
|
|
111
|
+
return self.s(truncate(label, width), DIM)
|
|
112
|
+
return self.s(label + "─" * (width - display_width(label)), DIM)
|
|
104
113
|
|
|
105
114
|
def table(
|
|
106
115
|
self, headers: list[str], rows: list[list[str]], cell_styles: list | None = None
|
|
107
116
|
) -> str:
|
|
108
|
-
"""Aligned table
|
|
117
|
+
"""Aligned, terminal-width-aware table with display-width styling."""
|
|
109
118
|
widths = [display_width(h) for h in headers]
|
|
110
119
|
for row in rows:
|
|
111
|
-
for i, cell in enumerate(row):
|
|
120
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
112
121
|
widths[i] = max(widths[i], display_width(cell))
|
|
122
|
+
spacing = " " if self.width >= 3 * len(headers) else " "
|
|
123
|
+
available = max(1, self.width - display_width(spacing) * (len(headers) - 1))
|
|
124
|
+
overflow = max(0, sum(widths) - available)
|
|
125
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
126
|
+
floor = min(widths[i], max(1, display_width(headers[i])))
|
|
127
|
+
reduction = min(overflow, widths[i] - floor)
|
|
128
|
+
widths[i] -= reduction
|
|
129
|
+
overflow -= reduction
|
|
130
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
131
|
+
reduction = min(overflow, widths[i] - 1)
|
|
132
|
+
widths[i] -= reduction
|
|
133
|
+
overflow -= reduction
|
|
134
|
+
shown_headers = [truncate(h, widths[i]) for i, h in enumerate(headers)]
|
|
113
135
|
head = self.s(
|
|
114
|
-
|
|
136
|
+
spacing.join(pad(shown_headers[i], widths[i]) for i in range(len(headers))),
|
|
137
|
+
BOLD,
|
|
138
|
+
DIM,
|
|
115
139
|
)
|
|
116
|
-
sep = self.s(
|
|
140
|
+
sep = self.s(spacing.join("─" * w for w in widths), DIM)
|
|
117
141
|
lines = [head, sep]
|
|
118
142
|
for row in rows:
|
|
119
143
|
cells = []
|
|
120
|
-
for i, cell in enumerate(row):
|
|
121
|
-
|
|
122
|
-
if cell_styles and cell_styles[i]
|
|
123
|
-
styled = cell_styles[i](cell)
|
|
144
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
145
|
+
shown = truncate(cell, widths[i])
|
|
146
|
+
styled = cell_styles[i](shown) if cell_styles and cell_styles[i] else shown
|
|
124
147
|
cells.append(pad(styled, widths[i]))
|
|
125
|
-
lines.append(
|
|
148
|
+
lines.append(spacing.join(cells))
|
|
126
149
|
return "\n".join(lines)
|
|
127
150
|
|
|
128
151
|
def status(self, text: str) -> str:
|
|
@@ -146,12 +169,15 @@ class UI:
|
|
|
146
169
|
print(self.s(text, GREEN))
|
|
147
170
|
|
|
148
171
|
def tool_call(self, name: str, args: str) -> None:
|
|
149
|
-
|
|
172
|
+
prefix = f" ⚒ {name} "
|
|
173
|
+
detail = truncate(args, max(8, self.width - display_width(prefix)))
|
|
174
|
+
print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(detail, DIM)}")
|
|
150
175
|
|
|
151
176
|
def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
|
|
152
177
|
mark = self.s("✓", GREEN) if ok else self.s("✗", RED)
|
|
153
178
|
tail = self.s(f" {elapsed:.1f}s", DIM) if elapsed is not None else ""
|
|
154
|
-
|
|
179
|
+
label = truncate(summary, max(8, self.width - 8 - display_width(tail)))
|
|
180
|
+
print(f" {mark} {self.s(label, GREEN if ok else RED)}{tail}")
|
|
155
181
|
|
|
156
182
|
def error(self, msg: str) -> None:
|
|
157
183
|
print(f" {self.s('!', RED)} {self.s(msg, RED)}")
|
|
@@ -188,15 +214,17 @@ class UI:
|
|
|
188
214
|
|
|
189
215
|
def banner(self, version: str, model: str, workspace: str) -> str:
|
|
190
216
|
rows = [
|
|
191
|
-
f"lithe {version}",
|
|
192
|
-
f"model
|
|
193
|
-
f"
|
|
194
|
-
"/help
|
|
217
|
+
f"lithe {version} · command agent",
|
|
218
|
+
f"◆ model {model}",
|
|
219
|
+
f"⌂ workspace {workspace}",
|
|
220
|
+
"/help 查看会话命令 · /exit 退出",
|
|
195
221
|
]
|
|
196
|
-
|
|
222
|
+
inner = max(8, self.width - 4)
|
|
223
|
+
rows = [truncate(row, inner - 2) for row in rows]
|
|
224
|
+
width = max(display_width(row) for row in rows) + 2
|
|
197
225
|
top = "╭" + "─" * width + "╮"
|
|
198
226
|
bot = "╰" + "─" * width + "╯"
|
|
199
|
-
body = "\n".join("│" + pad(" " +
|
|
227
|
+
body = "\n".join("│" + pad(" " + row, width) + "│" for row in rows)
|
|
200
228
|
art = "\n".join((top, body, bot))
|
|
201
229
|
return self.s(art, CYAN)
|
|
202
230
|
|