lithe-cli 0.2.4__tar.gz → 0.3.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.3.0}/PKG-INFO +9 -3
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/README.md +7 -2
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/main.py +6 -6
- lithe_cli-0.3.0/lithe_cli/prompts.py +118 -0
- lithe_cli-0.3.0/lithe_cli/setup.py +109 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/PKG-INFO +9 -3
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/SOURCES.txt +1 -1
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/requires.txt +1 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/pyproject.toml +1 -1
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/tests/test_cli.py +93 -125
- 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-0.3.0}/LICENSE +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/agent.py +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/config.py +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli/ui.py +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.0}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.2.4 → lithe_cli-0.3.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.3.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
|
|
|
@@ -40,6 +40,7 @@ Requires-Python: >=3.10
|
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
42
|
Requires-Dist: lithe>=0.9.5
|
|
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"
|
|
@@ -197,8 +198,12 @@ them only after a session has started the servers.)
|
|
|
197
198
|
### Colors
|
|
198
199
|
|
|
199
200
|
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.
|
|
201
|
+
/ `--no-color` force either way. Output rendering itself stays
|
|
202
|
+
dependency-free — plain text degrades cleanly through pipes and cron.
|
|
203
|
+
Interactive *input* (the chat prompt and the setup wizard) is
|
|
204
|
+
[prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
|
|
205
|
+
wide-character-safe editing, bracketed-paste handling, star-echoed API
|
|
206
|
+
keys, inline wizard validation, and persistent history.
|
|
202
207
|
|
|
203
208
|
## Where things land
|
|
204
209
|
|
|
@@ -208,6 +213,7 @@ text degrades cleanly through pipes and cron.
|
|
|
208
213
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
209
214
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
210
215
|
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
216
|
+
| `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
|
|
211
217
|
|
|
212
218
|
## Design notes
|
|
213
219
|
|
|
@@ -150,8 +150,12 @@ them only after a session has started the servers.)
|
|
|
150
150
|
### Colors
|
|
151
151
|
|
|
152
152
|
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.
|
|
153
|
+
/ `--no-color` force either way. Output rendering itself stays
|
|
154
|
+
dependency-free — plain text degrades cleanly through pipes and cron.
|
|
155
|
+
Interactive *input* (the chat prompt and the setup wizard) is
|
|
156
|
+
[prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
|
|
157
|
+
wide-character-safe editing, bracketed-paste handling, star-echoed API
|
|
158
|
+
keys, inline wizard validation, and persistent history.
|
|
155
159
|
|
|
156
160
|
## Where things land
|
|
157
161
|
|
|
@@ -161,6 +165,7 @@ text degrades cleanly through pipes and cron.
|
|
|
161
165
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
162
166
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
163
167
|
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
168
|
+
| `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
|
|
164
169
|
|
|
165
170
|
## Design notes
|
|
166
171
|
|
|
@@ -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
|
|
|
@@ -330,13 +331,13 @@ _CHAT_HELP = """可用命令:
|
|
|
330
331
|
def _chat_loop(cfg: Any) -> int:
|
|
331
332
|
store = JsonlRunStore(cfg.store_dir)
|
|
332
333
|
run_ids: list[str] = []
|
|
333
|
-
|
|
334
|
+
input_history = open_history() # FileHistory under $LITHE_HOME, shared runs
|
|
334
335
|
print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
|
|
335
336
|
while True:
|
|
336
337
|
try:
|
|
337
|
-
#
|
|
338
|
-
#
|
|
339
|
-
line =
|
|
338
|
+
# prompt_toolkit owns the terminal: wide-char safe, paste-safe,
|
|
339
|
+
# persistent history — never input()/readline
|
|
340
|
+
line = prompts.chat_line(ui.prompt(), input_history).strip()
|
|
340
341
|
except (EOFError, KeyboardInterrupt):
|
|
341
342
|
print()
|
|
342
343
|
break
|
|
@@ -351,7 +352,6 @@ def _chat_loop(cfg: Any) -> int:
|
|
|
351
352
|
continue
|
|
352
353
|
if not line:
|
|
353
354
|
continue
|
|
354
|
-
history.append(line)
|
|
355
355
|
if line in ("/exit", "/quit", "exit", "quit"):
|
|
356
356
|
break
|
|
357
357
|
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}
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.3.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
|
|
|
@@ -40,6 +40,7 @@ Requires-Python: >=3.10
|
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
42
|
Requires-Dist: lithe>=0.9.5
|
|
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"
|
|
@@ -197,8 +198,12 @@ them only after a session has started the servers.)
|
|
|
197
198
|
### Colors
|
|
198
199
|
|
|
199
200
|
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.
|
|
201
|
+
/ `--no-color` force either way. Output rendering itself stays
|
|
202
|
+
dependency-free — plain text degrades cleanly through pipes and cron.
|
|
203
|
+
Interactive *input* (the chat prompt and the setup wizard) is
|
|
204
|
+
[prompt_toolkit](https://python-prompt-toolkit.readthedocs.io/):
|
|
205
|
+
wide-character-safe editing, bracketed-paste handling, star-echoed API
|
|
206
|
+
keys, inline wizard validation, and persistent history.
|
|
202
207
|
|
|
203
208
|
## Where things land
|
|
204
209
|
|
|
@@ -208,6 +213,7 @@ text degrades cleanly through pipes and cron.
|
|
|
208
213
|
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
209
214
|
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
210
215
|
| `~/.lithe/skills` (or `--skills`) | the markdown skill library, when enabled |
|
|
216
|
+
| `~/.lithe/history` | chat input history (prompt_toolkit `FileHistory`; Up/Ctrl+R recall) |
|
|
211
217
|
|
|
212
218
|
## Design notes
|
|
213
219
|
|
|
@@ -9,7 +9,7 @@ description = "Command-line interface for the lithe agent kernel: chat, one-shot
|
|
|
9
9
|
readme = "README.md"
|
|
10
10
|
license = { file = "LICENSE" }
|
|
11
11
|
requires-python = ">=3.10"
|
|
12
|
-
dependencies = ["lithe>=0.9.5"]
|
|
12
|
+
dependencies = ["lithe>=0.9.5", "prompt-toolkit>=3.0.38"]
|
|
13
13
|
keywords = ["agent", "llm", "react", "cli", "tool-calling"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
@@ -350,15 +350,29 @@ def test_corrupt_config_file_ignored(tmp_path, monkeypatch):
|
|
|
350
350
|
|
|
351
351
|
|
|
352
352
|
def test_wizard_saves_and_reports_cancel(tmp_path, monkeypatch, capsys):
|
|
353
|
-
from lithe_cli import setup
|
|
353
|
+
from lithe_cli import prompts, setup
|
|
354
354
|
|
|
355
355
|
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
356
|
+
monkeypatch.setattr(setup, "stdin_is_interactive", lambda: True)
|
|
356
357
|
answers = iter(
|
|
357
|
-
["https://wizard.example/api/v1", "sk-wiz", "glm-4.6"
|
|
358
|
-
) # base_url, key, model
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
)
|
|
358
|
+
["https://wizard.example/api/v1", "sk-wiz", "glm-4.6"]
|
|
359
|
+
) # base_url, key, model
|
|
360
|
+
seen_kwargs = []
|
|
361
|
+
|
|
362
|
+
def fake_ask(label, default="", password=False, validate=None):
|
|
363
|
+
seen_kwargs.append(
|
|
364
|
+
{
|
|
365
|
+
"label": label,
|
|
366
|
+
"default": default,
|
|
367
|
+
"password": password,
|
|
368
|
+
"validate": validate,
|
|
369
|
+
}
|
|
370
|
+
)
|
|
371
|
+
return next(answers)
|
|
372
|
+
|
|
373
|
+
monkeypatch.setattr(prompts, "ask", fake_ask)
|
|
374
|
+
monkeypatch.setattr(prompts, "yes_no", lambda label, default: False)
|
|
375
|
+
saved = setup.run_setup_wizard()
|
|
362
376
|
assert saved == {
|
|
363
377
|
"api_key": "sk-wiz",
|
|
364
378
|
"base_url": "https://wizard.example/api/v1",
|
|
@@ -369,44 +383,19 @@ def test_wizard_saves_and_reports_cancel(tmp_path, monkeypatch, capsys):
|
|
|
369
383
|
from lithe_cli.config import load_saved_endpoint
|
|
370
384
|
|
|
371
385
|
assert load_saved_endpoint()["model"] == "glm-4.6"
|
|
386
|
+
# the URL is validated inline and the key is the only password prompt
|
|
387
|
+
assert seen_kwargs[0]["validate"] is setup._http_like
|
|
388
|
+
assert [k["password"] for k in seen_kwargs] == [False, True, False]
|
|
372
389
|
|
|
373
|
-
# bailing out (
|
|
374
|
-
# still saves nothing
|
|
390
|
+
# bailing out (Ctrl+D on the first question) returns None, saves nothing
|
|
375
391
|
(tmp_path / "config.json").unlink()
|
|
376
|
-
|
|
377
|
-
assert (
|
|
378
|
-
setup.run_setup_wizard(
|
|
379
|
-
input_fn=lambda prompt: next(empty), secret_fn=lambda prompt: next(empty)
|
|
380
|
-
)
|
|
381
|
-
is None
|
|
382
|
-
)
|
|
392
|
+
monkeypatch.setattr(prompts, "ask", lambda *a, **k: None)
|
|
393
|
+
assert setup.run_setup_wizard() is None
|
|
383
394
|
assert "已取消" in capsys.readouterr().out
|
|
384
395
|
assert load_saved_endpoint() == {}
|
|
385
396
|
|
|
386
397
|
|
|
387
|
-
def
|
|
388
|
-
from lithe_cli import setup
|
|
389
|
-
|
|
390
|
-
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
391
|
-
answers = iter(
|
|
392
|
-
[
|
|
393
|
-
"\x1b[200~https://paste.example/api/v1\x1b[201~",
|
|
394
|
-
"\x1b[200~sk-pasted-key \x1b[201~",
|
|
395
|
-
"\x1b[200~glm-4.6\x1b[201~",
|
|
396
|
-
"n",
|
|
397
|
-
]
|
|
398
|
-
)
|
|
399
|
-
saved = setup.run_setup_wizard(
|
|
400
|
-
input_fn=lambda prompt: next(answers), secret_fn=lambda prompt: next(answers)
|
|
401
|
-
)
|
|
402
|
-
assert saved == {
|
|
403
|
-
"api_key": "sk-pasted-key",
|
|
404
|
-
"base_url": "https://paste.example/api/v1",
|
|
405
|
-
"model": "glm-4.6",
|
|
406
|
-
}
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
def _pty_run(code: str, feed: list[bytes], settle: float = 0.35):
|
|
398
|
+
def _pty_run(code: str, feed: list[bytes], settle: float = 0.25):
|
|
410
399
|
"""Fork a PTY, run `code`, feed byte chunks, return (output, exit_code)."""
|
|
411
400
|
import os
|
|
412
401
|
import pty
|
|
@@ -447,136 +436,115 @@ def _pty_drain(fd) -> bytes:
|
|
|
447
436
|
if not chunk:
|
|
448
437
|
break
|
|
449
438
|
out += chunk
|
|
439
|
+
if b"\x1b[6n" in chunk:
|
|
440
|
+
# answer prompt_toolkit's cursor-position request, like a real
|
|
441
|
+
# terminal would — without a reply ptk falls into a degraded
|
|
442
|
+
# mode and key bindings (history recall) misbehave
|
|
443
|
+
os.write(fd, b"\x1b[1;1R")
|
|
450
444
|
return out
|
|
451
445
|
|
|
452
446
|
|
|
453
|
-
def
|
|
454
|
-
# the exact long Chinese line that readline corrupted in the field
|
|
447
|
+
def test_prompt_toolkit_cjk_paste_password_history(tmp_path):
|
|
448
|
+
# the exact long Chinese line that readline corrupted in the field,
|
|
449
|
+
# driven through the real prompt_toolkit stack
|
|
455
450
|
long_line = "你能使用python matplotlib 绘制 九大行星的运动动画,然后你把它运行起来。"
|
|
456
|
-
|
|
457
|
-
|
|
458
|
-
|
|
459
|
-
|
|
460
|
-
|
|
461
|
-
|
|
462
|
-
|
|
463
|
-
|
|
451
|
+
hist = str(tmp_path / "hist")
|
|
452
|
+
code = f"""
|
|
453
|
+
import os
|
|
454
|
+
from prompt_toolkit.history import FileHistory
|
|
455
|
+
from lithe_cli.prompts import ask, chat_line
|
|
456
|
+
from lithe_cli.setup import _http_like
|
|
457
|
+
hpath = {hist!r}
|
|
458
|
+
r1 = chat_line('\\x1b[36mlithe ❯ \\x1b[0m', FileHistory(hpath))
|
|
459
|
+
print('R1:' + repr(r1))
|
|
460
|
+
r2 = ask('API key', password=True)
|
|
461
|
+
print('R2:' + repr(r2))
|
|
462
|
+
r3 = ask('Base URL', validate=_http_like)
|
|
464
463
|
print('R3:' + repr(r3))
|
|
465
|
-
r4 =
|
|
464
|
+
r4 = chat_line('p4> ', FileHistory(hpath))
|
|
466
465
|
print('R4:' + repr(r4))
|
|
467
|
-
r5 =
|
|
466
|
+
r5 = chat_line('p5> ', FileHistory(hpath))
|
|
468
467
|
print('R5:' + repr(r5))
|
|
469
468
|
"""
|
|
470
|
-
# r1: IME-style bursts of the long line, with terminal focus junk injected
|
|
471
469
|
feed = []
|
|
472
470
|
msg = long_line.encode()
|
|
473
|
-
for i in range(0, len(msg), 6):
|
|
471
|
+
for i in range(0, len(msg), 6): # IME-style bursts
|
|
474
472
|
feed.append(msg[i : i + 6])
|
|
475
|
-
if i == 24:
|
|
476
|
-
feed.append(b"\x1b[I") # focus-in event must not pollute the line
|
|
477
473
|
feed.append(b"\r")
|
|
478
|
-
feed.append(b"
|
|
479
|
-
feed.append(b"\
|
|
480
|
-
feed.append("
|
|
481
|
-
feed.append(b"\
|
|
474
|
+
feed.append(b"sk-ptk\r") # password prompt
|
|
475
|
+
feed.append(b"no-scheme\r") # validator refuses Enter, shows the message
|
|
476
|
+
feed.append(b"\x7f" * 9) # wipe "no-scheme"…
|
|
477
|
+
feed.append(b"https://ok\r") # …type a valid URL
|
|
478
|
+
feed.append(b"\x1b[A\r") # Up recalls r1 from the history file
|
|
479
|
+
feed.append(b"\x1b[200~sk-paste\x1b[201~\r") # bracketed paste
|
|
482
480
|
out, rc = _pty_run(code, feed)
|
|
483
481
|
assert rc == 0
|
|
484
482
|
assert "UnicodeDecodeError" not in out
|
|
485
483
|
assert f"R1:{long_line!r}" in out
|
|
486
|
-
assert "R2:'
|
|
487
|
-
assert "R3:'
|
|
488
|
-
assert "R4:
|
|
489
|
-
assert "R5:'sk-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
def test_read_masked_echoes_stars_and_swallows_paste_markers():
|
|
493
|
-
import os
|
|
494
|
-
import pty
|
|
495
|
-
import select
|
|
496
|
-
import sys
|
|
497
|
-
import time
|
|
498
|
-
|
|
499
|
-
code = (
|
|
500
|
-
"from lithe_cli.setup import _read_masked;"
|
|
501
|
-
"print();print('GOT:' + _read_masked('API key: '))"
|
|
502
|
-
)
|
|
503
|
-
pid, fd = pty.fork()
|
|
504
|
-
if pid == 0:
|
|
505
|
-
env = dict(os.environ, PYTHONPATH=os.getcwd())
|
|
506
|
-
os.execvpe(sys.executable, [sys.executable, "-c", code], env)
|
|
507
|
-
os._exit(1)
|
|
508
|
-
try:
|
|
509
|
-
time.sleep(0.4)
|
|
510
|
-
os.write(fd, b"\x1b[200~sk-paste\x1b[201~\r") # pasted with wrappers
|
|
511
|
-
time.sleep(0.4)
|
|
512
|
-
out = b""
|
|
513
|
-
while select.select([fd], [], [], 0.2)[0]:
|
|
514
|
-
try:
|
|
515
|
-
chunk = os.read(fd, 4096)
|
|
516
|
-
except OSError:
|
|
517
|
-
break
|
|
518
|
-
if not chunk:
|
|
519
|
-
break
|
|
520
|
-
out += chunk
|
|
521
|
-
finally:
|
|
522
|
-
os.close(fd)
|
|
523
|
-
os.waitpid(pid, 0)
|
|
524
|
-
text = out.decode(errors="replace")
|
|
525
|
-
assert "GOT:sk-paste" in text # value is clean
|
|
526
|
-
assert "********" in text # one star per visible character
|
|
527
|
-
assert "\x1b[200~" not in text and "\x1b[201~" not in text
|
|
484
|
+
assert "R2:'sk-ptk'" in out and "******" in out # stars echoed
|
|
485
|
+
assert "R3:'https://ok'" in out and "http://" in out # inline validation
|
|
486
|
+
assert f"R4:{long_line!r}" in out # history recall across prompts
|
|
487
|
+
assert "R5:'sk-paste'" in out # paste wrappers consumed
|
|
488
|
+
assert "\x1b[200~" not in out
|
|
528
489
|
|
|
529
490
|
|
|
530
491
|
def test_undecodable_input_line_never_crashes(tmp_path, monkeypatch, capsys):
|
|
531
|
-
# a mangled (non-UTF-8) input line
|
|
532
|
-
#
|
|
533
|
-
def bad_decode(
|
|
492
|
+
# a mangled (non-UTF-8) input line must be dropped with a hint,
|
|
493
|
+
# not kill the whole process
|
|
494
|
+
def bad_decode(*a, **k):
|
|
534
495
|
raise UnicodeDecodeError("utf-8", b"\xe5", 52, 53, "invalid continuation byte")
|
|
535
496
|
|
|
536
|
-
from lithe_cli import
|
|
497
|
+
from lithe_cli import prompts
|
|
537
498
|
from lithe_cli.main import _chat_loop
|
|
538
499
|
from conftest import make_config
|
|
539
500
|
|
|
501
|
+
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
540
502
|
calls = iter([bad_decode, EOFError])
|
|
541
503
|
|
|
542
|
-
def
|
|
504
|
+
def fake_chat_line(message, history):
|
|
543
505
|
f = next(calls)
|
|
544
506
|
if isinstance(f, type(EOFError)):
|
|
545
507
|
raise f
|
|
546
|
-
f(
|
|
508
|
+
f(message, history)
|
|
547
509
|
|
|
548
|
-
monkeypatch.setattr("
|
|
510
|
+
monkeypatch.setattr(prompts, "chat_line", fake_chat_line)
|
|
549
511
|
assert _chat_loop(make_config(tmp_path, [])) == 0
|
|
550
512
|
out = capsys.readouterr().out
|
|
551
513
|
assert "UTF-8" in out
|
|
552
514
|
|
|
553
|
-
#
|
|
554
|
-
answers = iter([bad_decode, "
|
|
515
|
+
# ask() takes the same beating and re-asks instead of dying
|
|
516
|
+
answers = iter([bad_decode, "x"])
|
|
555
517
|
|
|
556
|
-
def
|
|
518
|
+
def fake_prompt(*a, **k):
|
|
557
519
|
f = next(answers)
|
|
558
520
|
if callable(f):
|
|
559
|
-
f(
|
|
560
|
-
|
|
521
|
+
f(*a, **k)
|
|
522
|
+
raise AssertionError("callable should have raised")
|
|
561
523
|
return f
|
|
562
524
|
|
|
563
|
-
|
|
564
|
-
|
|
565
|
-
== "https://ok.example/api"
|
|
566
|
-
)
|
|
525
|
+
monkeypatch.setattr(prompts, "prompt", fake_prompt)
|
|
526
|
+
assert prompts.ask("Label") == "x"
|
|
567
527
|
assert "UTF-8" in capsys.readouterr().out
|
|
568
528
|
|
|
569
529
|
|
|
570
|
-
def
|
|
571
|
-
from lithe_cli import
|
|
530
|
+
def test_ask_empty_retries_and_default_keeps(monkeypatch, capsys):
|
|
531
|
+
from lithe_cli import prompts
|
|
532
|
+
|
|
533
|
+
answers = iter(["", "", "", "leftover"])
|
|
534
|
+
|
|
535
|
+
def fake_prompt(*a, **k):
|
|
536
|
+
return next(answers)
|
|
537
|
+
|
|
538
|
+
monkeypatch.setattr(prompts, "prompt", fake_prompt)
|
|
539
|
+
assert prompts.ask("Label") is None # three empties → bail out
|
|
540
|
+
assert "不能为空" in capsys.readouterr().out
|
|
541
|
+
|
|
542
|
+
monkeypatch.setattr(
|
|
543
|
+
prompts, "prompt", lambda *a, **k: ""
|
|
544
|
+
) # empty keeps the default
|
|
545
|
+
assert prompts.ask("Label", default="kept") == "kept"
|
|
546
|
+
assert prompts.ask("Key", default="sk-saved", password=True) == "sk-saved"
|
|
572
547
|
|
|
573
|
-
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
574
|
-
answers = iter(["no-scheme.example", "https://ok.example/api", "sk-2", "m2", "n"])
|
|
575
|
-
saved = setup.run_setup_wizard(
|
|
576
|
-
input_fn=lambda prompt: next(answers), secret_fn=lambda prompt: next(answers)
|
|
577
|
-
)
|
|
578
|
-
assert saved is not None and saved["base_url"] == "https://ok.example/api"
|
|
579
|
-
assert "http" in capsys.readouterr().out # the validation complaint
|
|
580
548
|
|
|
581
549
|
|
|
582
550
|
def test_require_endpoint_wizard_only_on_tty(tmp_path, monkeypatch):
|
|
@@ -1,198 +0,0 @@
|
|
|
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 now 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.
|
|
11
|
-
"""
|
|
12
|
-
|
|
13
|
-
from __future__ import annotations
|
|
14
|
-
|
|
15
|
-
import os
|
|
16
|
-
import sys
|
|
17
|
-
from urllib.error import HTTPError, URLError
|
|
18
|
-
from urllib.request import Request, urlopen
|
|
19
|
-
|
|
20
|
-
from .config import (
|
|
21
|
-
ENV_API_KEY,
|
|
22
|
-
ENV_BASE_URL,
|
|
23
|
-
ENV_MODEL,
|
|
24
|
-
config_file,
|
|
25
|
-
load_saved_endpoint,
|
|
26
|
-
save_endpoint,
|
|
27
|
-
)
|
|
28
|
-
from .ttyio import _PASTE_MARKERS, read_line
|
|
29
|
-
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, ui
|
|
30
|
-
|
|
31
|
-
_MAX_EMPTY_TRIES = 3
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
def _clean_answer(text: str) -> str:
|
|
35
|
-
"""Strip whitespace plus bracketed-paste wrappers from a typed/pasted reply.
|
|
36
|
-
|
|
37
|
-
Shells (zsh, recent bash) leave the terminal in bracketed-paste mode
|
|
38
|
-
(DECSET 2004), so pasted text arrives wrapped in ``\\x1b[200~ ... \\x1b[201~``.
|
|
39
|
-
Neither ``input`` nor ``getpass`` removes those markers — a pasted API
|
|
40
|
-
key would be saved with them glued on and fail auth later.
|
|
41
|
-
"""
|
|
42
|
-
for marker in _PASTE_MARKERS:
|
|
43
|
-
text = text.replace(marker.decode(), "")
|
|
44
|
-
return text.strip()
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
def _read_masked(prompt: str = "") -> str:
|
|
48
|
-
"""getpass-style hidden read that echoes a ``*`` per accepted character.
|
|
49
|
-
|
|
50
|
-
Thin wrapper over :func:`lithe_cli.ttyio.read_line`; the why lives
|
|
51
|
-
there. Falls back to plain ``input()`` without a TTY, never getpass
|
|
52
|
-
silence: every accepted character is acknowledged.
|
|
53
|
-
"""
|
|
54
|
-
return read_line(prompt, mask=True)
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
def stdin_is_interactive() -> bool:
|
|
58
|
-
"""True only when both ends are TTYs — pipes and captures stay clean."""
|
|
59
|
-
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
def _prompt(
|
|
63
|
-
label: str,
|
|
64
|
-
default: str = "",
|
|
65
|
-
secret: bool = False,
|
|
66
|
-
validate=None,
|
|
67
|
-
input_fn=input,
|
|
68
|
-
secret_fn=_read_masked,
|
|
69
|
-
) -> str | None:
|
|
70
|
-
"""Ask for one value; brackets show a non-secret default when set.
|
|
71
|
-
|
|
72
|
-
``validate`` (value -> error string or None) re-prompts on bad input.
|
|
73
|
-
Returns None when the user bails out (empty too often, Ctrl+C/D).
|
|
74
|
-
"""
|
|
75
|
-
ask = secret_fn if secret else input_fn
|
|
76
|
-
for _ in range(_MAX_EMPTY_TRIES):
|
|
77
|
-
if secret and default:
|
|
78
|
-
suffix = " [回车沿用已保存值]"
|
|
79
|
-
elif default:
|
|
80
|
-
suffix = f" [{default}]"
|
|
81
|
-
else:
|
|
82
|
-
suffix = ""
|
|
83
|
-
try:
|
|
84
|
-
answer = _clean_answer(ask(f"{label}{suffix}: "))
|
|
85
|
-
except (EOFError, KeyboardInterrupt):
|
|
86
|
-
print()
|
|
87
|
-
return None
|
|
88
|
-
except UnicodeDecodeError:
|
|
89
|
-
print(
|
|
90
|
-
ui.s(
|
|
91
|
-
"\n 这行输入无法按 UTF-8 解码,已丢弃;"
|
|
92
|
-
"请把终端与 locale 设为 UTF-8(如 export LANG=C.UTF-8)后重试。",
|
|
93
|
-
RED,
|
|
94
|
-
)
|
|
95
|
-
)
|
|
96
|
-
continue
|
|
97
|
-
if not answer and default:
|
|
98
|
-
answer = default
|
|
99
|
-
if not answer:
|
|
100
|
-
print(ui.s(" 这个值不能为空。", YELLOW))
|
|
101
|
-
continue
|
|
102
|
-
if validate is not None:
|
|
103
|
-
problem = validate(answer)
|
|
104
|
-
if problem:
|
|
105
|
-
print(ui.s(f" {problem}", YELLOW))
|
|
106
|
-
continue
|
|
107
|
-
return answer
|
|
108
|
-
return None
|
|
109
|
-
|
|
110
|
-
|
|
111
|
-
def _yes_no(label: str, default: bool, input_fn=input) -> bool:
|
|
112
|
-
hint = "Y/n" if default else "y/N"
|
|
113
|
-
try:
|
|
114
|
-
answer = input_fn(f"{label} [{hint}]: ").strip().lower()
|
|
115
|
-
except (EOFError, KeyboardInterrupt):
|
|
116
|
-
print()
|
|
117
|
-
return default
|
|
118
|
-
except UnicodeDecodeError:
|
|
119
|
-
print(ui.s("\n 这行输入无法按 UTF-8 解码,按默认值处理。", RED))
|
|
120
|
-
return default
|
|
121
|
-
return default if not answer else answer in ("y", "yes", "是")
|
|
122
|
-
|
|
123
|
-
|
|
124
|
-
def _http_like(value: str) -> str | None:
|
|
125
|
-
return (
|
|
126
|
-
None
|
|
127
|
-
if value.startswith(("http://", "https://"))
|
|
128
|
-
else ("Base URL 需以 http:// 或 https:// 开头。")
|
|
129
|
-
)
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
def probe_endpoint(base_url: str, api_key: str, timeout: float = 10.0) -> bool:
|
|
133
|
-
"""One cheap GET ``{base_url}/models`` to catch typos before chat.
|
|
134
|
-
|
|
135
|
-
The save already happened: a failed probe warns, it never blocks —
|
|
136
|
-
endpoints legitimately lack ``/models`` or auth differently.
|
|
137
|
-
"""
|
|
138
|
-
url = base_url.rstrip("/") + "/models"
|
|
139
|
-
req = Request(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
140
|
-
try:
|
|
141
|
-
with urlopen(req, timeout=timeout) as resp:
|
|
142
|
-
print(ui.s(f"✓ 端点可达(HTTP {resp.status})", GREEN))
|
|
143
|
-
return True
|
|
144
|
-
except HTTPError as exc:
|
|
145
|
-
if exc.code in (401, 403):
|
|
146
|
-
print(ui.s(f"~ 端点可达但拒绝了该 key(HTTP {exc.code})", YELLOW))
|
|
147
|
-
else:
|
|
148
|
-
print(ui.s(f"~ 端点有响应:HTTP {exc.code}(无 /models 也无妨)", YELLOW))
|
|
149
|
-
return True # reachable is all we wanted to know
|
|
150
|
-
except (URLError, OSError, TimeoutError) as exc:
|
|
151
|
-
print(ui.s(f"✗ 连不上 {url}:{exc}", RED))
|
|
152
|
-
return False
|
|
153
|
-
|
|
154
|
-
|
|
155
|
-
def run_setup_wizard(input_fn=input, secret_fn=_read_masked) -> dict | None:
|
|
156
|
-
"""Prompt for base_url / api_key / model and save the config file.
|
|
157
|
-
|
|
158
|
-
Defaults come from the already-saved file, then env vars, so
|
|
159
|
-
re-running the wizard is re-editing, not retyping. Returns the saved
|
|
160
|
-
endpoint dict, or None when the user cancels.
|
|
161
|
-
"""
|
|
162
|
-
saved = load_saved_endpoint()
|
|
163
|
-
env = {
|
|
164
|
-
"base_url": os.environ.get(ENV_BASE_URL, "").strip(),
|
|
165
|
-
"api_key": os.environ.get(ENV_API_KEY, "").strip(),
|
|
166
|
-
"model": os.environ.get(ENV_MODEL, "").strip(),
|
|
167
|
-
}
|
|
168
|
-
defaults = {k: saved.get(k, "") or env[k] for k in env}
|
|
169
|
-
|
|
170
|
-
print(ui.s("初始设置:配置模型端点", CYAN, BOLD))
|
|
171
|
-
print(
|
|
172
|
-
f"值保存在 {config_file()}(权限 600);"
|
|
173
|
-
"优先级 flag > 环境变量 > 配置文件。\n"
|
|
174
|
-
"之后可用 `lithe-cli config` 重新配置。"
|
|
175
|
-
)
|
|
176
|
-
base_url = _prompt(
|
|
177
|
-
"Base URL(如 https://your-endpoint/api/v1)",
|
|
178
|
-
defaults["base_url"],
|
|
179
|
-
validate=_http_like,
|
|
180
|
-
input_fn=input_fn,
|
|
181
|
-
)
|
|
182
|
-
api_key = _prompt(
|
|
183
|
-
"API key",
|
|
184
|
-
defaults["api_key"],
|
|
185
|
-
secret=True,
|
|
186
|
-
input_fn=input_fn,
|
|
187
|
-
secret_fn=secret_fn,
|
|
188
|
-
)
|
|
189
|
-
model = _prompt("模型名(如 glm-4.6)", defaults["model"], input_fn=input_fn)
|
|
190
|
-
if not (base_url and api_key and model):
|
|
191
|
-
print(ui.s("已取消,未保存任何配置。", YELLOW))
|
|
192
|
-
return None
|
|
193
|
-
|
|
194
|
-
path = save_endpoint(api_key, base_url, model)
|
|
195
|
-
print(ui.s(f"✓ 已保存到 {path}", GREEN))
|
|
196
|
-
if _yes_no("现在测试一下连接吗", True, input_fn=input_fn):
|
|
197
|
-
probe_endpoint(base_url, api_key)
|
|
198
|
-
return {"api_key": api_key, "base_url": base_url, "model": model}
|
|
@@ -1,195 +0,0 @@
|
|
|
1
|
-
"""Raw-mode TTY line reading without GNU readline.
|
|
2
|
-
|
|
3
|
-
Why this exists: readline's redisplay machinery can diverge from its own
|
|
4
|
-
buffer on long multibyte lines (observed as ``input()`` raising
|
|
5
|
-
UnicodeDecodeError on a line the terminal echoed correctly — the buffer
|
|
6
|
-
lost a byte mid-character). We cannot fix C-level readline, so chat
|
|
7
|
-
input bypasses it entirely: this module reads bytes off ``/dev/tty`` in
|
|
8
|
-
cbreak mode, echoes exactly what arrived (the terminal wraps it — we
|
|
9
|
-
never compute columns), and supports the editing keys a chat REPL
|
|
10
|
-
needs: backspace, Ctrl+W, Ctrl+U, Up/Down history, Ctrl+C/Ctrl+D.
|
|
11
|
-
|
|
12
|
-
Bracketed-paste wrappers and unknown escape sequences (arrow keys other
|
|
13
|
-
than Up/Down, focus events, cursor replies…) are swallowed instead of
|
|
14
|
-
leaking into the returned line. Decoding is strict incremental UTF-8 —
|
|
15
|
-
a line that arrives broken raises UnicodeDecodeError for the caller to
|
|
16
|
-
drop with a hint, never silently mangled.
|
|
17
|
-
"""
|
|
18
|
-
|
|
19
|
-
from __future__ import annotations
|
|
20
|
-
|
|
21
|
-
import codecs
|
|
22
|
-
import os
|
|
23
|
-
import sys
|
|
24
|
-
import unicodedata
|
|
25
|
-
|
|
26
|
-
_PASTE_MARKERS = (b"\x1b[200~", b"\x1b[201~")
|
|
27
|
-
_UP = (b"\x1b[A", b"\x1bOA")
|
|
28
|
-
_DOWN = (b"\x1b[B", b"\x1bOB")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
def _width(ch: str) -> int:
|
|
32
|
-
return 2 if unicodedata.east_asian_width(ch) in ("W", "F") else 1
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
def read_line(
|
|
36
|
-
prompt: str = "",
|
|
37
|
-
*,
|
|
38
|
-
mask: bool = False,
|
|
39
|
-
history: list[str] | None = None,
|
|
40
|
-
input_fn=None,
|
|
41
|
-
) -> str:
|
|
42
|
-
"""Read one line from the TTY, echoing input (or ``*`` when masked).
|
|
43
|
-
|
|
44
|
-
Falls back to plain ``input()`` when there is no interactive
|
|
45
|
-
terminal (pipes, tests) or termios is unavailable. Raises
|
|
46
|
-
EOFError (Ctrl+D on empty), KeyboardInterrupt (Ctrl+C), and
|
|
47
|
-
UnicodeDecodeError for undecodable input.
|
|
48
|
-
"""
|
|
49
|
-
if input_fn is None:
|
|
50
|
-
import builtins
|
|
51
|
-
|
|
52
|
-
input_fn = builtins.input
|
|
53
|
-
if not (sys.stdin.isatty() and sys.stdout.isatty()):
|
|
54
|
-
return input_fn(prompt)
|
|
55
|
-
try:
|
|
56
|
-
import termios
|
|
57
|
-
except ImportError: # pragma: no cover — non-POSIX
|
|
58
|
-
return input_fn(prompt)
|
|
59
|
-
try:
|
|
60
|
-
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
61
|
-
except OSError: # no controlling terminal
|
|
62
|
-
return input_fn(prompt)
|
|
63
|
-
|
|
64
|
-
chars: list[str] = []
|
|
65
|
-
pending = b""
|
|
66
|
-
hist_index: int | None = None
|
|
67
|
-
draft = ""
|
|
68
|
-
decoder = codecs.getincrementaldecoder("utf-8")()
|
|
69
|
-
|
|
70
|
-
def emit(text: str) -> None:
|
|
71
|
-
os.write(tty_fd, text.encode(errors="ignore"))
|
|
72
|
-
|
|
73
|
-
def cells(ch: str) -> int:
|
|
74
|
-
return 1 if mask else _width(ch)
|
|
75
|
-
|
|
76
|
-
def show(ch: str) -> None:
|
|
77
|
-
emit("*" if mask else ch)
|
|
78
|
-
|
|
79
|
-
def rub_out(ch: str) -> None:
|
|
80
|
-
emit("\b \b" * cells(ch))
|
|
81
|
-
|
|
82
|
-
def replace_line(text: str) -> None:
|
|
83
|
-
for ch in reversed(chars):
|
|
84
|
-
rub_out(ch)
|
|
85
|
-
chars.clear()
|
|
86
|
-
chars.extend(text)
|
|
87
|
-
for ch in text:
|
|
88
|
-
show(ch)
|
|
89
|
-
|
|
90
|
-
def move_history(delta: int) -> None:
|
|
91
|
-
nonlocal hist_index, draft
|
|
92
|
-
if not history:
|
|
93
|
-
return
|
|
94
|
-
if delta < 0:
|
|
95
|
-
if hist_index is None:
|
|
96
|
-
draft = "".join(chars)
|
|
97
|
-
hist_index = len(history) - 1
|
|
98
|
-
else:
|
|
99
|
-
hist_index = max(0, hist_index - 1)
|
|
100
|
-
replace_line(history[hist_index])
|
|
101
|
-
else:
|
|
102
|
-
if hist_index is None:
|
|
103
|
-
return
|
|
104
|
-
if hist_index + 1 >= len(history):
|
|
105
|
-
hist_index = None
|
|
106
|
-
replace_line(draft)
|
|
107
|
-
else:
|
|
108
|
-
hist_index += 1
|
|
109
|
-
replace_line(history[hist_index])
|
|
110
|
-
|
|
111
|
-
def finish() -> str:
|
|
112
|
-
chars.append(decoder.decode(b"", final=True))
|
|
113
|
-
emit("\n")
|
|
114
|
-
return "".join(chars)
|
|
115
|
-
|
|
116
|
-
def literal(byte: int) -> str | None:
|
|
117
|
-
if byte in (0x0D, 0x0A):
|
|
118
|
-
return finish()
|
|
119
|
-
if byte in (0x7F, 0x08): # backspace rubs one character out
|
|
120
|
-
if chars:
|
|
121
|
-
rub_out(chars.pop())
|
|
122
|
-
return None
|
|
123
|
-
if byte == 0x15: # Ctrl+U wipes the line
|
|
124
|
-
for ch in reversed(chars):
|
|
125
|
-
rub_out(ch)
|
|
126
|
-
chars.clear()
|
|
127
|
-
return None
|
|
128
|
-
if byte == 0x17: # Ctrl+W rubs the last word
|
|
129
|
-
while chars and chars[-1] == " ":
|
|
130
|
-
rub_out(chars.pop())
|
|
131
|
-
while chars and chars[-1] != " ":
|
|
132
|
-
rub_out(chars.pop())
|
|
133
|
-
return None
|
|
134
|
-
if byte == 0x04: # Ctrl+D bails out on an empty line
|
|
135
|
-
if not chars:
|
|
136
|
-
raise EOFError
|
|
137
|
-
return finish()
|
|
138
|
-
if byte >= 0x20: # printable bytes; controls are ignored
|
|
139
|
-
piece = decoder.decode(bytes([byte]))
|
|
140
|
-
for ch in piece:
|
|
141
|
-
chars.append(ch)
|
|
142
|
-
show(ch)
|
|
143
|
-
return None
|
|
144
|
-
|
|
145
|
-
def dispatch(seq: bytes) -> None:
|
|
146
|
-
if seq in _UP:
|
|
147
|
-
move_history(-1)
|
|
148
|
-
elif seq in _DOWN:
|
|
149
|
-
move_history(1)
|
|
150
|
-
|
|
151
|
-
old = termios.tcgetattr(tty_fd)
|
|
152
|
-
new = termios.tcgetattr(tty_fd)
|
|
153
|
-
new[3] &= ~(termios.ECHO | termios.ICANON) # 3 == lflags
|
|
154
|
-
new[6][termios.VMIN] = 1 # 6 == cc: read(1) blocks per byte
|
|
155
|
-
new[6][termios.VTIME] = 0
|
|
156
|
-
try:
|
|
157
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, new)
|
|
158
|
-
emit(prompt)
|
|
159
|
-
while True:
|
|
160
|
-
chunk = os.read(tty_fd, 1)
|
|
161
|
-
if not chunk:
|
|
162
|
-
raise EOFError
|
|
163
|
-
byte = chunk[0]
|
|
164
|
-
if pending:
|
|
165
|
-
cand = pending + chunk
|
|
166
|
-
head = cand[:2]
|
|
167
|
-
if any(m.startswith(cand) for m in _PASTE_MARKERS):
|
|
168
|
-
pending = b"" if cand in _PASTE_MARKERS else cand
|
|
169
|
-
elif head == b"\x1b[":
|
|
170
|
-
if 0x40 <= byte <= 0x7E:
|
|
171
|
-
dispatch(cand)
|
|
172
|
-
pending = b""
|
|
173
|
-
else:
|
|
174
|
-
pending = cand
|
|
175
|
-
elif head == b"\x1bO":
|
|
176
|
-
if 0x20 <= byte <= 0x7E:
|
|
177
|
-
dispatch(cand)
|
|
178
|
-
pending = b""
|
|
179
|
-
else:
|
|
180
|
-
pending = cand
|
|
181
|
-
else:
|
|
182
|
-
pending = b"" # lone ESC: drop it, take the byte
|
|
183
|
-
line = literal(byte)
|
|
184
|
-
if line is not None:
|
|
185
|
-
return line
|
|
186
|
-
continue
|
|
187
|
-
if byte == 0x1B:
|
|
188
|
-
pending = chunk
|
|
189
|
-
continue
|
|
190
|
-
line = literal(byte)
|
|
191
|
-
if line is not None:
|
|
192
|
-
return line
|
|
193
|
-
finally:
|
|
194
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, old)
|
|
195
|
-
os.close(tty_fd)
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|