lithe-cli 0.2.0__tar.gz → 0.2.2__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.0
3
+ Version: 0.2.2
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
 
@@ -65,7 +65,14 @@ pip install lithe-cli
65
65
  ## Configure
66
66
 
67
67
  There is no default endpoint — the CLI refuses to run rather than silently
68
- hitting some third-party URL. Point it at any OpenAI-compatible endpoint:
68
+ hitting some third-party URL. The easiest way in is the wizard: on the
69
+ first `lithe chat` / `lithe run` (or any time, via `lithe config`) an
70
+ interactive terminal prompts for the three essentials and saves them to
71
+ `$LITHE_HOME/config.json` (mode 0600 — it holds the key). An optional
72
+ connectivity probe catches typos before your first turn.
73
+
74
+ To configure by hand instead, point the CLI at any OpenAI-compatible
75
+ endpoint:
69
76
 
70
77
  ```bash
71
78
  export LITHE_API_KEY=sk-...
@@ -73,13 +80,18 @@ export LITHE_BASE_URL=https://your-endpoint/api/v1
73
80
  export LITHE_MODEL=your-model
74
81
  ```
75
82
 
76
- Flags (`--api-key`, `--base-url`, `--model`) override the environment.
83
+ Resolution order is flag (`--api-key`, `--base-url`, `--model`) >
84
+ environment > saved config file, per key. The wizard never runs without
85
+ a TTY on both ends, so pipes and CI keep the hard refusal; `--no-setup`
86
+ restores that fail-fast behavior on terminals too. `lithe config --show`
87
+ peeks at the saved values with a masked key.
88
+
77
89
  `LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
78
90
  workspace defaults to the current directory (`--workspace` to change).
79
91
 
80
92
  `lithe doctor` prints the effective configuration — endpoint (with a
81
- masked key), store, workspace, sandbox backend, skills, MCP servers — so
82
- you can see what a run would use before starting one.
93
+ masked key), config file, store, workspace, sandbox backend, skills, MCP
94
+ servers — so you can see what a run would use before starting one.
83
95
 
84
96
  ## Use
85
97
 
@@ -18,7 +18,14 @@ pip install lithe-cli
18
18
  ## Configure
19
19
 
20
20
  There is no default endpoint — the CLI refuses to run rather than silently
21
- hitting some third-party URL. Point it at any OpenAI-compatible endpoint:
21
+ hitting some third-party URL. The easiest way in is the wizard: on the
22
+ first `lithe chat` / `lithe run` (or any time, via `lithe config`) an
23
+ interactive terminal prompts for the three essentials and saves them to
24
+ `$LITHE_HOME/config.json` (mode 0600 — it holds the key). An optional
25
+ connectivity probe catches typos before your first turn.
26
+
27
+ To configure by hand instead, point the CLI at any OpenAI-compatible
28
+ endpoint:
22
29
 
23
30
  ```bash
24
31
  export LITHE_API_KEY=sk-...
@@ -26,13 +33,18 @@ export LITHE_BASE_URL=https://your-endpoint/api/v1
26
33
  export LITHE_MODEL=your-model
27
34
  ```
28
35
 
29
- Flags (`--api-key`, `--base-url`, `--model`) override the environment.
36
+ Resolution order is flag (`--api-key`, `--base-url`, `--model`) >
37
+ environment > saved config file, per key. The wizard never runs without
38
+ a TTY on both ends, so pipes and CI keep the hard refusal; `--no-setup`
39
+ restores that fail-fast behavior on terminals too. `lithe config --show`
40
+ peeks at the saved values with a masked key.
41
+
30
42
  `LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
31
43
  workspace defaults to the current directory (`--workspace` to change).
32
44
 
33
45
  `lithe doctor` prints the effective configuration — endpoint (with a
34
- masked key), store, workspace, sandbox backend, skills, MCP servers — so
35
- you can see what a run would use before starting one.
46
+ masked key), config file, store, workspace, sandbox backend, skills, MCP
47
+ servers — so you can see what a run would use before starting one.
36
48
 
37
49
  ## Use
38
50
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.2.0"
3
+ __version__ = "0.2.2"
@@ -1,4 +1,5 @@
1
1
  """Support ``python -m lithe_cli`` alongside the console script."""
2
+
2
3
  import sys
3
4
 
4
5
  from .main import main
@@ -14,6 +14,7 @@ Everything here maps lithe's host contract onto CLI defaults:
14
14
  - undo — the bundled tools register reverters, so ``lithe undo`` works with
15
15
  zero configuration.
16
16
  """
17
+
17
18
  from __future__ import annotations
18
19
 
19
20
  import json
@@ -25,7 +26,11 @@ from typing import Any
25
26
 
26
27
  from lithe import AgentContext, LLMConfig, ToolRegistry
27
28
  from lithe.bundles import (
28
- AgentHost, JsonlRunStore, JsonTodoStore, Workspace, undo_run,
29
+ AgentHost,
30
+ JsonlRunStore,
31
+ JsonTodoStore,
32
+ Workspace,
33
+ undo_run,
29
34
  )
30
35
  from lithe.bundles.download import register_download_tools
31
36
  from lithe.bundles.patch import register_apply_patch_tool
@@ -91,8 +96,7 @@ def build_registry(cfg: Config) -> ToolRegistry:
91
96
  from lithe.bundles.sandbox import CodeRunner, register_code_tools
92
97
 
93
98
  runner = CodeRunner(sys.executable, backend=sandbox_backend())
94
- register_code_tools(
95
- reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
99
+ register_code_tools(reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
96
100
  if cfg.skills_dir is not None:
97
101
  from lithe.bundles.skills import SkillLibrary, register_skill_tool
98
102
 
@@ -109,13 +113,18 @@ def build_host(cfg: Config, reg: ToolRegistry) -> AgentHost:
109
113
  store = JsonlRunStore(cfg.store_dir)
110
114
  prompt = build_system_prompt(cfg)
111
115
  return AgentHost(
112
- reg, build_llm(cfg), store, max_steps=cfg.max_steps,
116
+ reg,
117
+ build_llm(cfg),
118
+ store,
119
+ max_steps=cfg.max_steps,
113
120
  build_system_prompt=lambda ctx, mode, anchor: prompt,
114
121
  )
115
122
 
116
123
 
117
124
  def render_event(
118
- ev: dict, verbose: bool = False, stream: bool = False,
125
+ ev: dict,
126
+ verbose: bool = False,
127
+ stream: bool = False,
119
128
  pending: dict | None = None,
120
129
  ) -> None:
121
130
  """Render one runtime event onto the terminal.
@@ -150,8 +159,14 @@ def render_event(
150
159
  elif t == "cancelled":
151
160
  ui.warn("已取消")
152
161
  elif verbose and t == "usage":
153
- print(ui.usage(ev.get("prompt_tokens"), ev.get("completion_tokens"),
154
- ev.get("context_tokens"), ev.get("context_percent")))
162
+ print(
163
+ ui.usage(
164
+ ev.get("prompt_tokens"),
165
+ ev.get("completion_tokens"),
166
+ ev.get("context_tokens"),
167
+ ev.get("context_percent"),
168
+ )
169
+ )
155
170
  elif verbose and t == "reasoning":
156
171
  digest = (ev.get("summary") or "").replace("\n", " ")
157
172
  if digest:
@@ -159,7 +174,9 @@ def render_event(
159
174
 
160
175
 
161
176
  async def execute(
162
- cfg: Config, task: str, history: list[dict] | None = None,
177
+ cfg: Config,
178
+ task: str,
179
+ history: list[dict] | None = None,
163
180
  run_id: str | None = None,
164
181
  ) -> tuple[str, dict, Any]:
165
182
  """Run one task end-to-end; returns (run_id, done_event, host)."""
@@ -1,9 +1,13 @@
1
- """Configuration resolved from CLI flags and the environment.
1
+ """Configuration resolved from CLI flags, the environment, and a saved file.
2
2
 
3
3
  Endpoint credentials follow the same convention as lithe's ``live_host``
4
- example: ``LITHE_API_KEY`` / ``LITHE_BASE_URL`` / ``LITHE_MODEL`` — flags
5
- override env vars. There is no default endpoint on purpose: the CLI refuses
6
- to run rather than silently hitting some third-party URL.
4
+ example: ``LITHE_API_KEY`` / ``LITHE_BASE_URL`` / ``LITHE_MODEL``. There
5
+ is no default endpoint on purpose: the CLI refuses to run rather than
6
+ silently hitting some third-party URL. Resolution order is flag > env >
7
+ ``$LITHE_HOME/config.json`` (written by the first-run wizard or
8
+ ``lithe-cli config``; mode 0600 since it holds the key). On an
9
+ interactive terminal a missing endpoint triggers the wizard instead of
10
+ the refusal — see :mod:`lithe_cli.setup`.
7
11
 
8
12
  Capability flags (all off unless asked for):
9
13
 
@@ -17,6 +21,7 @@ Capability flags (all off unless asked for):
17
21
  - ``--vision`` ``image_info`` + ``analyze_image`` (one vision call on
18
22
  the main endpoint, memoized per file hash).
19
23
  """
24
+
20
25
  from __future__ import annotations
21
26
 
22
27
  import json
@@ -101,20 +106,74 @@ def load_mcp_spec(raw: str) -> list:
101
106
  raise SystemExit(
102
107
  f"MCP 配置解析失败:{exc}\n"
103
108
  "spec 应为 JSON 数组(stdio: command/env,http: url/headers),"
104
- "例如:--mcp '[{\"name\":\"fs\",\"command\":[\"npx\",\"-y\","
105
- "\"@modelcontextprotocol/server-filesystem\",\".\"]}]'"
109
+ '例如:--mcp \'[{"name":"fs","command":["npx","-y",'
110
+ '"@modelcontextprotocol/server-filesystem","."]}]\''
106
111
  ) from exc
107
112
 
108
113
 
114
+ _ENDPOINT_KEYS = ("api_key", "base_url", "model")
115
+
116
+
117
+ def config_file() -> Path:
118
+ """Where the wizard-saved endpoint lives."""
119
+ return lithe_home() / "config.json"
120
+
121
+
122
+ def load_saved_endpoint() -> dict:
123
+ """Read {api_key, base_url, model} from the config file; {} if none.
124
+
125
+ A corrupt file is treated as absent — a bad edit should cost the user
126
+ one re-prompt, not a broken CLI.
127
+ """
128
+ try:
129
+ data = json.loads(config_file().read_text(encoding="utf-8"))
130
+ except (OSError, json.JSONDecodeError):
131
+ return {}
132
+ if not isinstance(data, dict):
133
+ return {}
134
+ return {
135
+ k: data[k].strip()
136
+ for k in _ENDPOINT_KEYS
137
+ if isinstance(data.get(k), str) and data[k].strip()
138
+ }
139
+
140
+
141
+ def save_endpoint(api_key: str, base_url: str, model: str) -> Path:
142
+ """Write the config file; 0600 because it holds the API key."""
143
+ path = config_file()
144
+ fresh_dir = not path.parent.exists()
145
+ path.parent.mkdir(parents=True, exist_ok=True)
146
+ if fresh_dir:
147
+ try:
148
+ path.parent.chmod(0o700)
149
+ except OSError:
150
+ pass
151
+ payload = {
152
+ k: v
153
+ for k, v in (("api_key", api_key), ("base_url", base_url), ("model", model))
154
+ }
155
+ path.write_text(
156
+ json.dumps(payload, ensure_ascii=False, indent=2) + "\n", encoding="utf-8"
157
+ )
158
+ try:
159
+ path.chmod(0o600)
160
+ except OSError:
161
+ pass
162
+ return path
163
+
164
+
109
165
  def load_config(args: Any) -> Config:
110
166
  """Merge parsed argparse flags over environment defaults."""
167
+
111
168
  def g(name: str, default):
112
169
  return getattr(args, name, default)
113
170
 
114
- store_dir = (Path(g("store", None)).expanduser()
115
- if g("store", None) else default_store_dir())
116
- workspace_dir = (Path(g("workspace", None)).expanduser()
117
- if g("workspace", None) else Path.cwd())
171
+ store_dir = (
172
+ Path(g("store", None)).expanduser() if g("store", None) else default_store_dir()
173
+ )
174
+ workspace_dir = (
175
+ Path(g("workspace", None)).expanduser() if g("workspace", None) else Path.cwd()
176
+ )
118
177
  skills = g("skills", None)
119
178
  if skills is not None and skills.strip():
120
179
  skills_dir: Path | None = Path(skills).expanduser()
@@ -125,10 +184,15 @@ def load_config(args: Any) -> Config:
125
184
  skills_dir = cand if cand.is_dir() else None
126
185
 
127
186
  mcp_raw = g("mcp", None) or os.environ.get(ENV_MCP)
187
+ saved = load_saved_endpoint() # flag > env > config file, per key
128
188
  return Config(
129
- api_key=g("api_key", None) or os.environ.get(ENV_API_KEY),
130
- base_url=g("base_url", None) or os.environ.get(ENV_BASE_URL),
131
- model=g("model", None) or os.environ.get(ENV_MODEL),
189
+ api_key=(
190
+ g("api_key", None) or os.environ.get(ENV_API_KEY) or saved.get("api_key")
191
+ ),
192
+ base_url=(
193
+ g("base_url", None) or os.environ.get(ENV_BASE_URL) or saved.get("base_url")
194
+ ),
195
+ model=(g("model", None) or os.environ.get(ENV_MODEL) or saved.get("model")),
132
196
  store_dir=store_dir,
133
197
  workspace_dir=workspace_dir,
134
198
  user_id=g("user", None) or DEFAULT_USER,
@@ -144,19 +208,35 @@ def load_config(args: Any) -> Config:
144
208
  mcp_spec=mcp_raw,
145
209
  mcp_servers=load_mcp_spec(mcp_raw) if mcp_raw else [],
146
210
  vision=g("vision", False),
147
- color=(True if g("color", False) else
148
- False if g("no_color", False) else None),
211
+ color=(True if g("color", False) else False if g("no_color", False) else None),
149
212
  )
150
213
 
151
214
 
152
- def require_endpoint(cfg: Config) -> None:
153
- """Exit with a how-to message when no endpoint is configured."""
215
+ def require_endpoint(cfg: Config, interactive: bool = False) -> None:
216
+ """Ensure an endpoint, launching the first-run wizard when it helps.
217
+
218
+ ``interactive`` asks for the wizard; it still only runs on a TTY (both
219
+ ends), so pipes, captures and CI keep the plain SystemExit.
220
+ """
154
221
  if cfg.has_endpoint:
155
222
  return
223
+ if interactive:
224
+ from .setup import run_setup_wizard, stdin_is_interactive
225
+
226
+ if stdin_is_interactive():
227
+ saved = run_setup_wizard() or {}
228
+ cfg.api_key = cfg.api_key or saved.get("api_key")
229
+ cfg.base_url = cfg.base_url or saved.get("base_url")
230
+ cfg.model = cfg.model or saved.get("model")
231
+ if cfg.has_endpoint:
232
+ return
156
233
  raise SystemExit(
157
- "缺少模型端点配置。请设置环境变量(或使用对应flag):\n"
158
- f" export {ENV_API_KEY}=sk-...\n"
159
- f" export {ENV_BASE_URL}=https://your-endpoint/api/v1\n"
160
- f" export {ENV_MODEL}=your-model\n"
234
+ "缺少模型端点配置。两种方式任选:\n"
235
+ " 1) 交互式(推荐):运行 lithe-cli config,按提示输入后保存到\n"
236
+ f" {config_file()}\n"
237
+ " 2) 环境变量(或对应flag):\n"
238
+ f" export {ENV_API_KEY}=sk-...\n"
239
+ f" export {ENV_BASE_URL}=https://your-endpoint/api/v1\n"
240
+ f" export {ENV_MODEL}=your-model\n"
161
241
  "本 CLI 不内置任何默认端点。"
162
242
  )
@@ -8,11 +8,16 @@ Subcommands:
8
8
  log RUN_ID show a stored run's messages and actions
9
9
  undo RUN_ID revert a run's file/todo mutations
10
10
  doctor show the effective configuration and capability status
11
+ config interactive endpoint setup (first-run wizard; --show to peek)
11
12
 
12
13
  Endpoint config: LITHE_API_KEY / LITHE_BASE_URL / LITHE_MODEL (flags
13
- override). Storage lands in ~/.lithe/runs (LITHE_HOME to move it); the
14
- agent's workspace is the current directory unless --workspace says else.
14
+ override env, env overrides the wizard-saved $LITHE_HOME/config.json).
15
+ On a TTY, run/chat with no endpoint at all launch the setup wizard
16
+ (--no-setup keeps the hard refusal for scripting). Storage lands in
17
+ ~/.lithe/runs (LITHE_HOME to move it); the agent's workspace is the
18
+ current directory unless --workspace says else.
15
19
  """
20
+
16
21
  from __future__ import annotations
17
22
 
18
23
  import argparse
@@ -25,7 +30,7 @@ from lithe.bundles import JsonlRunStore
25
30
 
26
31
  from . import __version__
27
32
  from .agent import build_registry, execute, sandbox_backend, undo
28
- from .config import load_config, require_endpoint
33
+ from .config import config_file, load_config, require_endpoint
29
34
  from .ui import BOLD, CYAN, GREEN, RED, YELLOW, configure, truncate, ui
30
35
 
31
36
 
@@ -34,8 +39,11 @@ def build_parser() -> argparse.ArgumentParser:
34
39
  prog="lithe",
35
40
  description="Command-line interface for the lithe agent kernel.",
36
41
  )
37
- p.add_argument("--version", action="version",
38
- version=f"lithe-cli {__version__} (lithe {lithe_version})")
42
+ p.add_argument(
43
+ "--version",
44
+ action="version",
45
+ version=f"lithe-cli {__version__} (lithe {lithe_version})",
46
+ )
39
47
  sub = p.add_subparsers(dest="command")
40
48
 
41
49
  def common(sp: argparse.ArgumentParser) -> None:
@@ -45,40 +53,71 @@ def build_parser() -> argparse.ArgumentParser:
45
53
  sp.add_argument("--workspace", help="agent workspace dir (default: cwd)")
46
54
  sp.add_argument("--store", help="run store dir (default: ~/.lithe/runs)")
47
55
  sp.add_argument("--user", help="user id for the store (default: cli)")
48
- sp.add_argument("--download", action="store_true",
49
- help="grant the agent download_file (network fetch)")
50
- sp.add_argument("--code", action="store_true",
51
- help="grant run_code/run_file (sandboxed Python; "
52
- "bwrap when available, passthrough otherwise)")
53
- sp.add_argument("--skills", metavar="DIR",
54
- help='markdown skill dir (default: $LITHE_HOME/skills '
55
- "when it exists; \"\" disables)")
56
- sp.add_argument("--mcp", metavar="SPEC",
57
- help="MCP servers as JSON array/object or @file "
58
- "(env LITHE_MCP)")
59
- sp.add_argument("--vision", action="store_true",
60
- help="grant image_info/analyze_image (vision on the "
61
- "main endpoint)")
56
+ sp.add_argument(
57
+ "--download",
58
+ action="store_true",
59
+ help="grant the agent download_file (network fetch)",
60
+ )
61
+ sp.add_argument(
62
+ "--code",
63
+ action="store_true",
64
+ help="grant run_code/run_file (sandboxed Python; "
65
+ "bwrap when available, passthrough otherwise)",
66
+ )
67
+ sp.add_argument(
68
+ "--skills",
69
+ metavar="DIR",
70
+ help="markdown skill dir (default: $LITHE_HOME/skills "
71
+ 'when it exists; "" disables)',
72
+ )
73
+ sp.add_argument(
74
+ "--mcp",
75
+ metavar="SPEC",
76
+ help="MCP servers as JSON array/object or @file (env LITHE_MCP)",
77
+ )
78
+ sp.add_argument(
79
+ "--vision",
80
+ action="store_true",
81
+ help="grant image_info/analyze_image (vision on the main endpoint)",
82
+ )
62
83
  color = sp.add_mutually_exclusive_group()
63
- color.add_argument("--color", action="store_true",
64
- help="force colored output")
65
- color.add_argument("--no-color", action="store_true",
66
- help="disable colored output")
67
- sp.add_argument("--verbose", "-v", action="store_true",
68
- help="show usage/reasoning events")
84
+ color.add_argument("--color", action="store_true", help="force colored output")
85
+ color.add_argument(
86
+ "--no-color", action="store_true", help="disable colored output"
87
+ )
88
+ sp.add_argument(
89
+ "--verbose", "-v", action="store_true", help="show usage/reasoning events"
90
+ )
69
91
 
70
92
  def runtime(sp: argparse.ArgumentParser) -> None:
71
93
  common(sp)
72
- sp.add_argument("--stream", action="store_true",
73
- help="stream tokens as they generate")
74
- sp.add_argument("--max-steps", type=int, default=35,
75
- help="tool-loop step budget (default: 35)")
76
- sp.add_argument("--context-window", type=int, default=None,
77
- help="model context window, for fullness gauges")
78
- sp.add_argument("--timeout", type=float, default=180.0,
79
- help="per-call timeout in seconds")
80
- sp.add_argument("--attempts", type=int, default=2,
81
- help="per-call retry attempts")
94
+ sp.add_argument(
95
+ "--stream", action="store_true", help="stream tokens as they generate"
96
+ )
97
+ sp.add_argument(
98
+ "--no-setup",
99
+ action="store_true",
100
+ help="skip the first-run endpoint wizard (fail hard "
101
+ "when unconfigured instead of prompting)",
102
+ )
103
+ sp.add_argument(
104
+ "--max-steps",
105
+ type=int,
106
+ default=35,
107
+ help="tool-loop step budget (default: 35)",
108
+ )
109
+ sp.add_argument(
110
+ "--context-window",
111
+ type=int,
112
+ default=None,
113
+ help="model context window, for fullness gauges",
114
+ )
115
+ sp.add_argument(
116
+ "--timeout", type=float, default=180.0, help="per-call timeout in seconds"
117
+ )
118
+ sp.add_argument(
119
+ "--attempts", type=int, default=2, help="per-call retry attempts"
120
+ )
82
121
 
83
122
  run_p = sub.add_parser("run", help="run one task")
84
123
  runtime(run_p)
@@ -104,6 +143,15 @@ def build_parser() -> argparse.ArgumentParser:
104
143
 
105
144
  sub.add_parser("doctor", help="show configuration and capability status")
106
145
 
146
+ config_p = sub.add_parser(
147
+ "config", help="interactive endpoint setup (first-run wizard)"
148
+ )
149
+ config_p.add_argument(
150
+ "--show",
151
+ action="store_true",
152
+ help="print the saved config (key masked) and exit",
153
+ )
154
+
107
155
  return p
108
156
 
109
157
 
@@ -128,11 +176,14 @@ def _cmd_runs(cfg: Any, limit: int) -> int:
128
176
  rows = []
129
177
  for r in reversed(runs): # newest last, like a log
130
178
  task = (r.task or "").replace("\n", " ")
131
- rows.append([r.run_id, r.status, str(r.steps),
132
- f"{r.cost:.4f}", task[:60]])
133
- print(ui.table(
134
- ["run", "status", "steps", "cost", "task"], rows,
135
- cell_styles=[ui.s, ui.status, ui.s, ui.s, ui.s]))
179
+ rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", task[:60]])
180
+ print(
181
+ ui.table(
182
+ ["run", "status", "steps", "cost", "task"],
183
+ rows,
184
+ cell_styles=[ui.s, ui.status, ui.s, ui.s, ui.s],
185
+ )
186
+ )
136
187
  return 0
137
188
 
138
189
 
@@ -142,8 +193,10 @@ def _cmd_log(cfg: Any, run_id: str) -> int:
142
193
  if run is None:
143
194
  print(ui.s(f"找不到 run {run_id}(user={cfg.user_id})", RED))
144
195
  return 1
145
- print(f"run {ui.s(run.run_id, CYAN)} status={ui.status(run.status)} "
146
- f"steps={run.steps} model={run.model}")
196
+ print(
197
+ f"run {ui.s(run.run_id, CYAN)} status={ui.status(run.status)} "
198
+ f"steps={run.steps} model={run.model}"
199
+ )
147
200
  print(f"task: {run.task}")
148
201
  if run.final:
149
202
  print(f"final: {run.final[:500]}")
@@ -164,7 +217,7 @@ def _cmd_log(cfg: Any, run_id: str) -> int:
164
217
 
165
218
 
166
219
  def _cmd_doctor() -> int:
167
- from .config import default_skills_dir
220
+ from .config import default_skills_dir, load_saved_endpoint
168
221
 
169
222
  class _Args: # doctor reuses load_config over env-only defaults
170
223
  api_key = base_url = model = store = workspace = user = None
@@ -181,14 +234,22 @@ def _cmd_doctor() -> int:
181
234
  return shown
182
235
 
183
236
  if cfg.has_endpoint:
184
- endpoint = (f"{cfg.model} @ {cfg.base_url}(key: {mask(cfg.api_key)})")
237
+ endpoint = f"{cfg.model} @ {cfg.base_url}(key: {mask(cfg.api_key)})"
185
238
  else:
186
- endpoint = ui.s("未配置(run/chat 前需设置 LITHE_API_KEY 等)", RED)
239
+ endpoint = ui.s(
240
+ "未配置(run/chat 会进入交互设置,或先运行 lithe-cli config)", RED
241
+ )
187
242
  print(ui.kv("endpoint", endpoint))
188
243
 
244
+ saved = load_saved_endpoint()
245
+ if saved:
246
+ state = f"已保存 {saved['model']} @ {saved['base_url']}"
247
+ else:
248
+ state = ui.s("未保存(lithe-cli config 可交互配置)", YELLOW)
249
+ print(ui.kv("config", f"{config_file()}({state})"))
250
+
189
251
  try:
190
- n_runs = len(JsonlRunStore(cfg.store_dir)
191
- .list_runs(cfg.user_id, limit=10_000))
252
+ n_runs = len(JsonlRunStore(cfg.store_dir).list_runs(cfg.user_id, limit=10_000))
192
253
  store_line = f"{cfg.store_dir}({n_runs} 个 run)"
193
254
  except OSError as exc:
194
255
  store_line = ui.s(f"{cfg.store_dir}(读取失败:{exc})", RED)
@@ -220,11 +281,44 @@ def _cmd_doctor() -> int:
220
281
 
221
282
 
222
283
  def _cmd_run(cfg: Any, task: str) -> int:
223
- require_endpoint(cfg)
224
284
  _, done, _ = asyncio.run(execute(cfg, task))
225
285
  return 0 if done.get("status") == "done" else 1
226
286
 
227
287
 
288
+ def _cmd_config(show: bool) -> int:
289
+ """`lithe-cli config [--show]`: run or inspect the endpoint wizard."""
290
+ from .config import load_saved_endpoint
291
+ from .setup import run_setup_wizard, stdin_is_interactive
292
+
293
+ saved = load_saved_endpoint()
294
+ if show:
295
+ if saved:
296
+ key = saved["api_key"]
297
+ masked = key if len(key) <= 8 else f"{key[:5]}…{key[-4:]}"
298
+ print(ui.kv("config", str(config_file())))
299
+ print(ui.kv("base_url", saved["base_url"]))
300
+ print(ui.kv("api_key", masked))
301
+ print(ui.kv("model", saved["model"]))
302
+ else:
303
+ print(
304
+ ui.s(
305
+ f"({config_file()} 尚未创建;不带 --show 运行本命令进入交互配置)",
306
+ YELLOW,
307
+ )
308
+ )
309
+ return 0
310
+ if not stdin_is_interactive():
311
+ print(
312
+ ui.s(
313
+ "交互配置需要终端(当前 stdin 不是 TTY)。\n"
314
+ "请改用环境变量:LITHE_API_KEY / LITHE_BASE_URL / LITHE_MODEL。",
315
+ RED,
316
+ )
317
+ )
318
+ return 1
319
+ return 0 if run_setup_wizard() is not None else 1
320
+
321
+
228
322
  _CHAT_HELP = """可用命令:
229
323
  /help 显示这段帮助
230
324
  /tools 列出当前注册的工具
@@ -235,9 +329,7 @@ _CHAT_HELP = """可用命令:
235
329
  def _chat_loop(cfg: Any) -> int:
236
330
  store = JsonlRunStore(cfg.store_dir)
237
331
  run_ids: list[str] = []
238
- print(ui.banner(__version__,
239
- cfg.model or "(scripted)",
240
- str(cfg.workspace_dir)))
332
+ print(ui.banner(__version__, cfg.model or "(scripted)", str(cfg.workspace_dir)))
241
333
  while True:
242
334
  try:
243
335
  line = input(ui.prompt()).strip()
@@ -261,8 +353,7 @@ def _chat_loop(cfg: Any) -> int:
261
353
  if line.startswith("/"):
262
354
  print(ui.s(f"未知命令 {line}(/help 查看可用命令)", RED))
263
355
  continue
264
- history = (store.messages_for_runs(run_ids, cfg.user_id)
265
- if run_ids else None)
356
+ history = store.messages_for_runs(run_ids, cfg.user_id) if run_ids else None
266
357
  try:
267
358
  # One asyncio.run per turn: each run is self-contained, and the
268
359
  # blocking input() stays out of async context.
@@ -284,9 +375,10 @@ def main(argv: list[str] | None = None) -> int:
284
375
  cfg = load_config(args)
285
376
  configure(cfg.color)
286
377
  if args.command == "run":
378
+ require_endpoint(cfg, interactive=not args.no_setup)
287
379
  return _cmd_run(cfg, args.task)
288
380
  if args.command == "chat":
289
- require_endpoint(cfg)
381
+ require_endpoint(cfg, interactive=not args.no_setup)
290
382
  return _chat_loop(cfg)
291
383
  if args.command == "tools":
292
384
  return _cmd_tools(cfg)
@@ -298,6 +390,8 @@ def main(argv: list[str] | None = None) -> int:
298
390
  return asyncio.run(undo(cfg, args.run_id))
299
391
  if args.command == "doctor":
300
392
  return _cmd_doctor()
393
+ if args.command == "config":
394
+ return _cmd_config(args.show)
301
395
  return 2
302
396
 
303
397