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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.2.4
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.4"
3
+ __version__ = "0.3.0"
@@ -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 ttyio
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
- history: list[str] = []
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
- # raw cbreak reader, not input(): readline's redisplay can
338
- # corrupt multibyte buffers on long wrapped lines
339
- line = ttyio.read_line(ui.prompt(), history=history).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()
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.2.4
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,9 +6,9 @@ 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
- lithe_cli/ttyio.py
12
12
  lithe_cli/ui.py
13
13
  lithe_cli.egg-info/PKG-INFO
14
14
  lithe_cli.egg-info/SOURCES.txt
@@ -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,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 (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 _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 test_read_line_multibyte_history_editing_and_paste():
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
- code = """
457
- from lithe_cli import ttyio
458
- h = []
459
- r1 = ttyio.read_line('p1> ', history=h)
460
- print('R1:' + repr(r1)); h.append(r1)
461
- r2 = ttyio.read_line('p2> ', history=h)
462
- print('R2:' + repr(r2)); h.append(r2)
463
- r3 = ttyio.read_line('p3> ', history=h)
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 = ttyio.read_line('k> ', mask=True)
464
+ r4 = chat_line('p4> ', FileHistory(hpath))
466
465
  print('R4:' + repr(r4))
467
- r5 = ttyio.read_line('p5> ')
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"ab\x7fc\r") # r2: backspace → "ac"
479
- feed.append(b"\x1b[A\r") # r3: Up recalls "ac"
480
- feed.append("密钥\r".encode()) # r4: masked non-ASCII stays intact
481
- feed.append(b"\x1b[200~sk-x\x1b[201~\r") # r5: paste wrappers stripped
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:'ac'" in out
487
- assert "R3:'ac'" in out
488
- assert "R4:'密钥'" in out
489
- assert "R5:'sk-x'" in out
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 — e.g. readline/locale mismatch —
532
- # must be dropped with a hint, not kill the whole process
533
- 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):
534
495
  raise UnicodeDecodeError("utf-8", b"\xe5", 52, 53, "invalid continuation byte")
535
496
 
536
- from lithe_cli import setup
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 fake_input(prompt=""):
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(prompt)
508
+ f(message, history)
547
509
 
548
- monkeypatch.setattr("builtins.input", fake_input)
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
- # the wizard prompts take the same beating and re-ask instead of dying
554
- 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"])
555
517
 
556
- def wizard_input(prompt=""):
518
+ def fake_prompt(*a, **k):
557
519
  f = next(answers)
558
520
  if callable(f):
559
- f(prompt)
560
- return ""
521
+ f(*a, **k)
522
+ raise AssertionError("callable should have raised")
561
523
  return f
562
524
 
563
- assert (
564
- setup._prompt("Base URL", validate=setup._http_like, input_fn=wizard_input)
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 test_wizard_rejects_bad_url_then_accepts(tmp_path, monkeypatch, capsys):
571
- 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"
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