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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.2.3
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. Rendering has zero dependencies — plain
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. Rendering has zero dependencies — plain
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
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.2.3"
3
+ __version__ = "0.3.0"
@@ -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
- line = input(ui.prompt()).strip()
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.2.3
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. Rendering has zero dependencies — plain
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
 
@@ -6,6 +6,7 @@ lithe_cli/__main__.py
6
6
  lithe_cli/agent.py
7
7
  lithe_cli/config.py
8
8
  lithe_cli/main.py
9
+ lithe_cli/prompts.py
9
10
  lithe_cli/py.typed
10
11
  lithe_cli/setup.py
11
12
  lithe_cli/ui.py
@@ -1,4 +1,5 @@
1
1
  lithe>=0.9.5
2
+ prompt-toolkit>=3.0.38
2
3
 
3
4
  [dev]
4
5
  pytest>=8
@@ -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", "n"]
358
- ) # base_url, key, model, skip probe
359
- saved = setup.run_setup_wizard(
360
- input_fn=lambda prompt: next(answers), secret_fn=lambda prompt: next(answers)
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 (empty answers, nothing saved yet) returns None and
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
- empty = iter([""] * 12)
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 test_wizard_accepts_bracketed_paste(tmp_path, monkeypatch):
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 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(sys.executable, [sys.executable, "-c", code], env)
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
- while select.select([fd], [], [], 0.2)[0]:
431
- try:
432
- chunk = os.read(fd, 4096)
433
- except OSError:
434
- break
435
- if not chunk:
436
- break
437
- out += chunk
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
- os.waitpid(pid, 0)
441
- text = out.decode(errors="replace")
442
- assert "GOT:sk-paste" in text # value is clean
443
- assert "********" in text # one star per visible character
444
- assert "\x1b[200~" not in text and "\x1b[201~" not in text
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 — e.g. readline/locale mismatch —
449
- # must be dropped with a hint, not kill the whole process
450
- def bad_decode(prompt=""):
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 setup
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 fake_input(prompt=""):
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(prompt)
508
+ f(message, history)
464
509
 
465
- monkeypatch.setattr("builtins.input", fake_input)
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
- # the wizard prompts take the same beating and re-ask instead of dying
471
- answers = iter([bad_decode, "https://ok.example/api"])
515
+ # ask() takes the same beating and re-asks instead of dying
516
+ answers = iter([bad_decode, "x"])
472
517
 
473
- def wizard_input(prompt=""):
518
+ def fake_prompt(*a, **k):
474
519
  f = next(answers)
475
520
  if callable(f):
476
- f(prompt)
477
- return ""
521
+ f(*a, **k)
522
+ raise AssertionError("callable should have raised")
478
523
  return f
479
524
 
480
- assert (
481
- setup._prompt("Base URL", validate=setup._http_like, input_fn=wizard_input)
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 test_wizard_rejects_bad_url_then_accepts(tmp_path, monkeypatch, capsys):
488
- from lithe_cli import setup
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