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.
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/PKG-INFO +17 -5
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/README.md +16 -4
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/__main__.py +1 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/agent.py +25 -8
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/config.py +101 -21
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/main.py +148 -54
- lithe_cli-0.2.2/lithe_cli/setup.py +257 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/ui.py +14 -10
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/PKG-INFO +17 -5
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/SOURCES.txt +1 -0
- lithe_cli-0.2.2/tests/test_cli.py +511 -0
- lithe_cli-0.2.0/tests/test_cli.py +0 -247
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/LICENSE +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/requires.txt +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/pyproject.toml +0 -0
- {lithe_cli-0.2.0 → lithe_cli-0.2.2}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.2.
|
|
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.
|
|
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
|
-
|
|
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
|
|
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.
|
|
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
|
-
|
|
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
|
|
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
|
|
|
@@ -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,
|
|
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,
|
|
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,
|
|
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(
|
|
154
|
-
|
|
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,
|
|
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
|
|
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
|
|
5
|
-
|
|
6
|
-
|
|
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
|
-
|
|
105
|
-
"
|
|
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 = (
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
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=
|
|
130
|
-
|
|
131
|
-
|
|
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
|
-
"""
|
|
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
|
-
"
|
|
158
|
-
|
|
159
|
-
f"
|
|
160
|
-
|
|
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
|
|
14
|
-
|
|
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(
|
|
38
|
-
|
|
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(
|
|
49
|
-
|
|
50
|
-
|
|
51
|
-
|
|
52
|
-
|
|
53
|
-
sp.add_argument(
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
sp.add_argument(
|
|
60
|
-
|
|
61
|
-
|
|
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
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
sp.add_argument(
|
|
68
|
-
|
|
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(
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
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
|
-
|
|
133
|
-
|
|
134
|
-
|
|
135
|
-
|
|
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(
|
|
146
|
-
|
|
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 =
|
|
237
|
+
endpoint = f"{cfg.model} @ {cfg.base_url}(key: {mask(cfg.api_key)})"
|
|
185
238
|
else:
|
|
186
|
-
endpoint = ui.s(
|
|
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 =
|
|
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
|
|