lithe-cli 0.2.3__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.3 → lithe_cli-0.3.0}/PKG-INFO +9 -3
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/README.md +7 -2
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/main.py +6 -1
- 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.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/PKG-INFO +9 -3
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/SOURCES.txt +1 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/requires.txt +1 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/pyproject.toml +1 -1
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/tests/test_cli.py +136 -85
- lithe_cli-0.2.3/lithe_cli/setup.py +0 -269
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/LICENSE +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/agent.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/config.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli/ui.py +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.2.3 → lithe_cli-0.3.0}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.2.3 → 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,8 +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
|
|
35
|
+
from .prompts import open_history
|
|
34
36
|
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
|
|
35
37
|
|
|
36
38
|
|
|
@@ -329,10 +331,13 @@ _CHAT_HELP = """可用命令:
|
|
|
329
331
|
def _chat_loop(cfg: Any) -> int:
|
|
330
332
|
store = JsonlRunStore(cfg.store_dir)
|
|
331
333
|
run_ids: list[str] = []
|
|
334
|
+
input_history = open_history() # FileHistory under $LITHE_HOME, shared runs
|
|
332
335
|
print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
|
|
333
336
|
while True:
|
|
334
337
|
try:
|
|
335
|
-
|
|
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()
|
|
336
341
|
except (EOFError, KeyboardInterrupt):
|
|
337
342
|
print()
|
|
338
343
|
break
|
|
@@ -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,131 +383,168 @@ 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
|
-
|
|
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 test_read_masked_echoes_stars_and_swallows_paste_markers():
|
|
398
|
+
def _pty_run(code: str, feed: list[bytes], settle: float = 0.25):
|
|
399
|
+
"""Fork a PTY, run `code`, feed byte chunks, return (output, exit_code)."""
|
|
410
400
|
import os
|
|
411
401
|
import pty
|
|
412
|
-
import select
|
|
413
402
|
import sys
|
|
414
403
|
import time
|
|
415
404
|
|
|
416
|
-
code = (
|
|
417
|
-
"from lithe_cli.setup import _read_masked;"
|
|
418
|
-
"print();print('GOT:' + _read_masked('API key: '))"
|
|
419
|
-
)
|
|
420
405
|
pid, fd = pty.fork()
|
|
421
406
|
if pid == 0:
|
|
422
407
|
env = dict(os.environ, PYTHONPATH=os.getcwd())
|
|
423
|
-
os.execvpe(
|
|
408
|
+
os.execvpe(
|
|
409
|
+
sys.executable, [sys.executable, "-c", code], env
|
|
410
|
+
)
|
|
424
411
|
os._exit(1)
|
|
425
412
|
try:
|
|
426
|
-
time.sleep(0.4)
|
|
427
|
-
os.write(fd, b"\x1b[200~sk-paste\x1b[201~\r") # pasted with wrappers
|
|
428
|
-
time.sleep(0.4)
|
|
429
413
|
out = b""
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
414
|
+
for chunk in feed:
|
|
415
|
+
time.sleep(settle)
|
|
416
|
+
out += _pty_drain(fd)
|
|
417
|
+
os.write(fd, chunk)
|
|
418
|
+
time.sleep(settle)
|
|
419
|
+
out += _pty_drain(fd)
|
|
420
|
+
_, status = os.waitpid(pid, 0)
|
|
421
|
+
return out.decode(errors="replace"), os.waitstatus_to_exitcode(status)
|
|
438
422
|
finally:
|
|
439
423
|
os.close(fd)
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
|
|
424
|
+
|
|
425
|
+
|
|
426
|
+
def _pty_drain(fd) -> bytes:
|
|
427
|
+
import os
|
|
428
|
+
import select
|
|
429
|
+
|
|
430
|
+
out = b""
|
|
431
|
+
while select.select([fd], [], [], 0.2)[0]:
|
|
432
|
+
try:
|
|
433
|
+
chunk = os.read(fd, 4096)
|
|
434
|
+
except OSError:
|
|
435
|
+
break
|
|
436
|
+
if not chunk:
|
|
437
|
+
break
|
|
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")
|
|
444
|
+
return out
|
|
445
|
+
|
|
446
|
+
|
|
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
|
|
450
|
+
long_line = "你能使用python matplotlib 绘制 九大行星的运动动画,然后你把它运行起来。"
|
|
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)
|
|
463
|
+
print('R3:' + repr(r3))
|
|
464
|
+
r4 = chat_line('p4> ', FileHistory(hpath))
|
|
465
|
+
print('R4:' + repr(r4))
|
|
466
|
+
r5 = chat_line('p5> ', FileHistory(hpath))
|
|
467
|
+
print('R5:' + repr(r5))
|
|
468
|
+
"""
|
|
469
|
+
feed = []
|
|
470
|
+
msg = long_line.encode()
|
|
471
|
+
for i in range(0, len(msg), 6): # IME-style bursts
|
|
472
|
+
feed.append(msg[i : i + 6])
|
|
473
|
+
feed.append(b"\r")
|
|
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
|
|
480
|
+
out, rc = _pty_run(code, feed)
|
|
481
|
+
assert rc == 0
|
|
482
|
+
assert "UnicodeDecodeError" not in out
|
|
483
|
+
assert f"R1:{long_line!r}" in out
|
|
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
|
|
445
489
|
|
|
446
490
|
|
|
447
491
|
def test_undecodable_input_line_never_crashes(tmp_path, monkeypatch, capsys):
|
|
448
|
-
# a mangled (non-UTF-8) input line
|
|
449
|
-
#
|
|
450
|
-
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):
|
|
451
495
|
raise UnicodeDecodeError("utf-8", b"\xe5", 52, 53, "invalid continuation byte")
|
|
452
496
|
|
|
453
|
-
from lithe_cli import
|
|
497
|
+
from lithe_cli import prompts
|
|
454
498
|
from lithe_cli.main import _chat_loop
|
|
455
499
|
from conftest import make_config
|
|
456
500
|
|
|
501
|
+
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
457
502
|
calls = iter([bad_decode, EOFError])
|
|
458
503
|
|
|
459
|
-
def
|
|
504
|
+
def fake_chat_line(message, history):
|
|
460
505
|
f = next(calls)
|
|
461
506
|
if isinstance(f, type(EOFError)):
|
|
462
507
|
raise f
|
|
463
|
-
f(
|
|
508
|
+
f(message, history)
|
|
464
509
|
|
|
465
|
-
monkeypatch.setattr("
|
|
510
|
+
monkeypatch.setattr(prompts, "chat_line", fake_chat_line)
|
|
466
511
|
assert _chat_loop(make_config(tmp_path, [])) == 0
|
|
467
512
|
out = capsys.readouterr().out
|
|
468
513
|
assert "UTF-8" in out
|
|
469
514
|
|
|
470
|
-
#
|
|
471
|
-
answers = iter([bad_decode, "
|
|
515
|
+
# ask() takes the same beating and re-asks instead of dying
|
|
516
|
+
answers = iter([bad_decode, "x"])
|
|
472
517
|
|
|
473
|
-
def
|
|
518
|
+
def fake_prompt(*a, **k):
|
|
474
519
|
f = next(answers)
|
|
475
520
|
if callable(f):
|
|
476
|
-
f(
|
|
477
|
-
|
|
521
|
+
f(*a, **k)
|
|
522
|
+
raise AssertionError("callable should have raised")
|
|
478
523
|
return f
|
|
479
524
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
== "https://ok.example/api"
|
|
483
|
-
)
|
|
525
|
+
monkeypatch.setattr(prompts, "prompt", fake_prompt)
|
|
526
|
+
assert prompts.ask("Label") == "x"
|
|
484
527
|
assert "UTF-8" in capsys.readouterr().out
|
|
485
528
|
|
|
486
529
|
|
|
487
|
-
def
|
|
488
|
-
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"
|
|
489
547
|
|
|
490
|
-
monkeypatch.setenv("LITHE_HOME", str(tmp_path))
|
|
491
|
-
answers = iter(["no-scheme.example", "https://ok.example/api", "sk-2", "m2", "n"])
|
|
492
|
-
saved = setup.run_setup_wizard(
|
|
493
|
-
input_fn=lambda prompt: next(answers), secret_fn=lambda prompt: next(answers)
|
|
494
|
-
)
|
|
495
|
-
assert saved is not None and saved["base_url"] == "https://ok.example/api"
|
|
496
|
-
assert "http" in capsys.readouterr().out # the validation complaint
|
|
497
548
|
|
|
498
549
|
|
|
499
550
|
def test_require_endpoint_wizard_only_on_tty(tmp_path, monkeypatch):
|
|
@@ -1,269 +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 getpass import getpass
|
|
18
|
-
from urllib.error import HTTPError, URLError
|
|
19
|
-
from urllib.request import Request, urlopen
|
|
20
|
-
|
|
21
|
-
from .config import (
|
|
22
|
-
ENV_API_KEY,
|
|
23
|
-
ENV_BASE_URL,
|
|
24
|
-
ENV_MODEL,
|
|
25
|
-
config_file,
|
|
26
|
-
load_saved_endpoint,
|
|
27
|
-
save_endpoint,
|
|
28
|
-
)
|
|
29
|
-
from .ui import BOLD, CYAN, GREEN, RED, YELLOW, ui
|
|
30
|
-
|
|
31
|
-
_MAX_EMPTY_TRIES = 3
|
|
32
|
-
|
|
33
|
-
_PASTE_MARKERS = ("\x1b[200~", "\x1b[201~")
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
def _clean_answer(text: str) -> str:
|
|
37
|
-
"""Strip whitespace plus bracketed-paste wrappers from a typed/pasted reply.
|
|
38
|
-
|
|
39
|
-
Shells (zsh, recent bash) leave the terminal in bracketed-paste mode
|
|
40
|
-
(DECSET 2004), so pasted text arrives wrapped in ``\\x1b[200~ ... \\x1b[201~``.
|
|
41
|
-
Neither ``input`` nor ``getpass`` removes those markers — a pasted API
|
|
42
|
-
key would be saved with them glued on and fail auth later.
|
|
43
|
-
"""
|
|
44
|
-
for marker in _PASTE_MARKERS:
|
|
45
|
-
text = text.replace(marker, "")
|
|
46
|
-
return text.strip()
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
def _read_masked(prompt: str = "") -> str:
|
|
50
|
-
"""getpass-style hidden read that echoes a ``*`` per accepted character.
|
|
51
|
-
|
|
52
|
-
getpass is fully silent, so a pasted key looks like nothing happened
|
|
53
|
-
and users retype it by hand. This reader shows one star per character
|
|
54
|
-
(paste wrappers and editing keys echo nothing), supports backspace and
|
|
55
|
-
Ctrl+U, and falls back to plain getpass where raw termios is
|
|
56
|
-
unavailable (no controlling terminal, Windows).
|
|
57
|
-
"""
|
|
58
|
-
try:
|
|
59
|
-
import termios
|
|
60
|
-
except ImportError: # pragma: no cover — non-POSIX
|
|
61
|
-
return getpass(prompt)
|
|
62
|
-
try:
|
|
63
|
-
tty_fd = os.open("/dev/tty", os.O_RDWR)
|
|
64
|
-
except OSError: # no controlling terminal → silent fallback
|
|
65
|
-
return getpass(prompt)
|
|
66
|
-
|
|
67
|
-
chars: list[str] = []
|
|
68
|
-
pending = ""
|
|
69
|
-
|
|
70
|
-
def emit(text: str) -> None:
|
|
71
|
-
os.write(tty_fd, text.encode(errors="ignore"))
|
|
72
|
-
|
|
73
|
-
def plain(ch: str) -> str | None:
|
|
74
|
-
if ch in ("\r", "\n"):
|
|
75
|
-
return "".join(chars)
|
|
76
|
-
if ch in ("\x7f", "\b"): # backspace rubs out one star
|
|
77
|
-
if chars:
|
|
78
|
-
chars.pop()
|
|
79
|
-
emit("\b \b")
|
|
80
|
-
return None
|
|
81
|
-
if ch == "\x15": # Ctrl+U wipes the line
|
|
82
|
-
emit("\b \b" * len(chars))
|
|
83
|
-
chars.clear()
|
|
84
|
-
return None
|
|
85
|
-
if ch == "\x04": # Ctrl+D bails out on an empty line
|
|
86
|
-
if not chars:
|
|
87
|
-
raise EOFError
|
|
88
|
-
return "".join(chars)
|
|
89
|
-
if ch.isprintable():
|
|
90
|
-
chars.append(ch)
|
|
91
|
-
emit("*")
|
|
92
|
-
return None
|
|
93
|
-
|
|
94
|
-
old = termios.tcgetattr(tty_fd)
|
|
95
|
-
new = termios.tcgetattr(tty_fd)
|
|
96
|
-
new[3] &= ~(termios.ECHO | termios.ICANON) # 3 == lflags
|
|
97
|
-
new[6][termios.VMIN] = 1 # 6 == cc: read(1) blocks per char
|
|
98
|
-
new[6][termios.VTIME] = 0
|
|
99
|
-
try:
|
|
100
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, new)
|
|
101
|
-
emit(prompt)
|
|
102
|
-
while True:
|
|
103
|
-
byte = os.read(tty_fd, 1)
|
|
104
|
-
if not byte:
|
|
105
|
-
raise EOFError
|
|
106
|
-
ch = byte.decode(errors="replace")
|
|
107
|
-
if pending or ch == "\x1b":
|
|
108
|
-
candidate = pending + ch
|
|
109
|
-
if any(m.startswith(candidate) for m in _PASTE_MARKERS):
|
|
110
|
-
pending = "" if candidate in _PASTE_MARKERS else candidate
|
|
111
|
-
continue
|
|
112
|
-
pending = ""
|
|
113
|
-
line = None
|
|
114
|
-
for single in candidate: # not a wrapper: replay as input
|
|
115
|
-
line = plain(single)
|
|
116
|
-
if line is not None:
|
|
117
|
-
break
|
|
118
|
-
else:
|
|
119
|
-
line = plain(ch)
|
|
120
|
-
if line is not None:
|
|
121
|
-
emit("\n")
|
|
122
|
-
return line
|
|
123
|
-
finally:
|
|
124
|
-
termios.tcsetattr(tty_fd, termios.TCSANOW, old)
|
|
125
|
-
os.close(tty_fd)
|
|
126
|
-
|
|
127
|
-
|
|
128
|
-
def stdin_is_interactive() -> bool:
|
|
129
|
-
"""True only when both ends are TTYs — pipes and captures stay clean."""
|
|
130
|
-
return sys.stdin.isatty() and sys.stdout.isatty()
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
def _prompt(
|
|
134
|
-
label: str,
|
|
135
|
-
default: str = "",
|
|
136
|
-
secret: bool = False,
|
|
137
|
-
validate=None,
|
|
138
|
-
input_fn=input,
|
|
139
|
-
secret_fn=_read_masked,
|
|
140
|
-
) -> str | None:
|
|
141
|
-
"""Ask for one value; brackets show a non-secret default when set.
|
|
142
|
-
|
|
143
|
-
``validate`` (value -> error string or None) re-prompts on bad input.
|
|
144
|
-
Returns None when the user bails out (empty too often, Ctrl+C/D).
|
|
145
|
-
"""
|
|
146
|
-
ask = secret_fn if secret else input_fn
|
|
147
|
-
for _ in range(_MAX_EMPTY_TRIES):
|
|
148
|
-
if secret and default:
|
|
149
|
-
suffix = " [回车沿用已保存值]"
|
|
150
|
-
elif default:
|
|
151
|
-
suffix = f" [{default}]"
|
|
152
|
-
else:
|
|
153
|
-
suffix = ""
|
|
154
|
-
try:
|
|
155
|
-
answer = _clean_answer(ask(f"{label}{suffix}: "))
|
|
156
|
-
except (EOFError, KeyboardInterrupt):
|
|
157
|
-
print()
|
|
158
|
-
return None
|
|
159
|
-
except UnicodeDecodeError:
|
|
160
|
-
print(
|
|
161
|
-
ui.s(
|
|
162
|
-
"\n 这行输入无法按 UTF-8 解码,已丢弃;"
|
|
163
|
-
"请把终端与 locale 设为 UTF-8(如 export LANG=C.UTF-8)后重试。",
|
|
164
|
-
RED,
|
|
165
|
-
)
|
|
166
|
-
)
|
|
167
|
-
continue
|
|
168
|
-
if not answer and default:
|
|
169
|
-
answer = default
|
|
170
|
-
if not answer:
|
|
171
|
-
print(ui.s(" 这个值不能为空。", YELLOW))
|
|
172
|
-
continue
|
|
173
|
-
if validate is not None:
|
|
174
|
-
problem = validate(answer)
|
|
175
|
-
if problem:
|
|
176
|
-
print(ui.s(f" {problem}", YELLOW))
|
|
177
|
-
continue
|
|
178
|
-
return answer
|
|
179
|
-
return None
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
def _yes_no(label: str, default: bool, input_fn=input) -> bool:
|
|
183
|
-
hint = "Y/n" if default else "y/N"
|
|
184
|
-
try:
|
|
185
|
-
answer = input_fn(f"{label} [{hint}]: ").strip().lower()
|
|
186
|
-
except (EOFError, KeyboardInterrupt):
|
|
187
|
-
print()
|
|
188
|
-
return default
|
|
189
|
-
except UnicodeDecodeError:
|
|
190
|
-
print(ui.s("\n 这行输入无法按 UTF-8 解码,按默认值处理。", RED))
|
|
191
|
-
return default
|
|
192
|
-
return default if not answer else answer in ("y", "yes", "是")
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
def _http_like(value: str) -> str | None:
|
|
196
|
-
return (
|
|
197
|
-
None
|
|
198
|
-
if value.startswith(("http://", "https://"))
|
|
199
|
-
else ("Base URL 需以 http:// 或 https:// 开头。")
|
|
200
|
-
)
|
|
201
|
-
|
|
202
|
-
|
|
203
|
-
def probe_endpoint(base_url: str, api_key: str, timeout: float = 10.0) -> bool:
|
|
204
|
-
"""One cheap GET ``{base_url}/models`` to catch typos before chat.
|
|
205
|
-
|
|
206
|
-
The save already happened: a failed probe warns, it never blocks —
|
|
207
|
-
endpoints legitimately lack ``/models`` or auth differently.
|
|
208
|
-
"""
|
|
209
|
-
url = base_url.rstrip("/") + "/models"
|
|
210
|
-
req = Request(url, headers={"Authorization": f"Bearer {api_key}"})
|
|
211
|
-
try:
|
|
212
|
-
with urlopen(req, timeout=timeout) as resp:
|
|
213
|
-
print(ui.s(f"✓ 端点可达(HTTP {resp.status})", GREEN))
|
|
214
|
-
return True
|
|
215
|
-
except HTTPError as exc:
|
|
216
|
-
if exc.code in (401, 403):
|
|
217
|
-
print(ui.s(f"~ 端点可达但拒绝了该 key(HTTP {exc.code})", YELLOW))
|
|
218
|
-
else:
|
|
219
|
-
print(ui.s(f"~ 端点有响应:HTTP {exc.code}(无 /models 也无妨)", YELLOW))
|
|
220
|
-
return True # reachable is all we wanted to know
|
|
221
|
-
except (URLError, OSError, TimeoutError) as exc:
|
|
222
|
-
print(ui.s(f"✗ 连不上 {url}:{exc}", RED))
|
|
223
|
-
return False
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
def run_setup_wizard(input_fn=input, secret_fn=_read_masked) -> dict | None:
|
|
227
|
-
"""Prompt for base_url / api_key / model and save the config file.
|
|
228
|
-
|
|
229
|
-
Defaults come from the already-saved file, then env vars, so
|
|
230
|
-
re-running the wizard is re-editing, not retyping. Returns the saved
|
|
231
|
-
endpoint dict, or None when the user cancels.
|
|
232
|
-
"""
|
|
233
|
-
saved = load_saved_endpoint()
|
|
234
|
-
env = {
|
|
235
|
-
"base_url": os.environ.get(ENV_BASE_URL, "").strip(),
|
|
236
|
-
"api_key": os.environ.get(ENV_API_KEY, "").strip(),
|
|
237
|
-
"model": os.environ.get(ENV_MODEL, "").strip(),
|
|
238
|
-
}
|
|
239
|
-
defaults = {k: saved.get(k, "") or env[k] for k in env}
|
|
240
|
-
|
|
241
|
-
print(ui.s("初始设置:配置模型端点", CYAN, BOLD))
|
|
242
|
-
print(
|
|
243
|
-
f"值保存在 {config_file()}(权限 600);"
|
|
244
|
-
"优先级 flag > 环境变量 > 配置文件。\n"
|
|
245
|
-
"之后可用 `lithe-cli config` 重新配置。"
|
|
246
|
-
)
|
|
247
|
-
base_url = _prompt(
|
|
248
|
-
"Base URL(如 https://your-endpoint/api/v1)",
|
|
249
|
-
defaults["base_url"],
|
|
250
|
-
validate=_http_like,
|
|
251
|
-
input_fn=input_fn,
|
|
252
|
-
)
|
|
253
|
-
api_key = _prompt(
|
|
254
|
-
"API key",
|
|
255
|
-
defaults["api_key"],
|
|
256
|
-
secret=True,
|
|
257
|
-
input_fn=input_fn,
|
|
258
|
-
secret_fn=secret_fn,
|
|
259
|
-
)
|
|
260
|
-
model = _prompt("模型名(如 glm-4.6)", defaults["model"], input_fn=input_fn)
|
|
261
|
-
if not (base_url and api_key and model):
|
|
262
|
-
print(ui.s("已取消,未保存任何配置。", YELLOW))
|
|
263
|
-
return None
|
|
264
|
-
|
|
265
|
-
path = save_endpoint(api_key, base_url, model)
|
|
266
|
-
print(ui.s(f"✓ 已保存到 {path}", GREEN))
|
|
267
|
-
if _yes_no("现在测试一下连接吗", True, input_fn=input_fn):
|
|
268
|
-
probe_endpoint(base_url, api_key)
|
|
269
|
-
return {"api_key": api_key, "base_url": base_url, "model": model}
|
|
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
|