lithe-cli 0.2.0__tar.gz → 0.2.3__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.3
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.3"
@@ -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
  )