lithe-cli 0.3.0__tar.gz → 0.4.1__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.3.0 → lithe_cli-0.4.1}/PKG-INFO +5 -3
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/README.md +3 -1
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/__init__.py +1 -1
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/agent.py +14 -5
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/config.py +2 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/main.py +9 -2
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/ui.py +49 -21
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/PKG-INFO +5 -3
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/requires.txt +1 -1
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/pyproject.toml +1 -1
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/tests/test_cli.py +40 -1
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/LICENSE +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/__main__.py +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/prompts.py +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/py.typed +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli/setup.py +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/SOURCES.txt +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/dependency_links.txt +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/entry_points.txt +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/lithe_cli.egg-info/top_level.txt +0 -0
- {lithe_cli-0.3.0 → lithe_cli-0.4.1}/setup.cfg +0 -0
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
|
@@ -39,7 +39,7 @@ Classifier: Typing :: Typed
|
|
|
39
39
|
Requires-Python: >=3.10
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: lithe>=0.9.
|
|
42
|
+
Requires-Dist: lithe>=0.9.8
|
|
43
43
|
Requires-Dist: prompt-toolkit>=3.0.38
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -118,7 +118,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
118
118
|
```bash
|
|
119
119
|
$ lithe chat
|
|
120
120
|
╭──────────────────────────────────────╮
|
|
121
|
-
│ lithe 0.
|
|
121
|
+
│ lithe 0.4.0 │
|
|
122
122
|
│ model your-model │
|
|
123
123
|
│ workdir /home/me/my-project │
|
|
124
124
|
│ /help 查看会话命令,/exit 退出 │
|
|
@@ -142,6 +142,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
142
142
|
| Flag | Tools granted | Notes |
|
|
143
143
|
| --- | --- | --- |
|
|
144
144
|
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
145
|
+
| `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
|
|
145
146
|
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
146
147
|
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
147
148
|
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
@@ -149,6 +150,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
149
150
|
|
|
150
151
|
```bash
|
|
151
152
|
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
153
|
+
$ lithe run --shell "检查当前目录的项目状态"
|
|
152
154
|
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
153
155
|
```
|
|
154
156
|
|
|
@@ -70,7 +70,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
70
70
|
```bash
|
|
71
71
|
$ lithe chat
|
|
72
72
|
╭──────────────────────────────────────╮
|
|
73
|
-
│ lithe 0.
|
|
73
|
+
│ lithe 0.4.0 │
|
|
74
74
|
│ model your-model │
|
|
75
75
|
│ workdir /home/me/my-project │
|
|
76
76
|
│ /help 查看会话命令,/exit 退出 │
|
|
@@ -94,6 +94,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
94
94
|
| Flag | Tools granted | Notes |
|
|
95
95
|
| --- | --- | --- |
|
|
96
96
|
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
97
|
+
| `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
|
|
97
98
|
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
98
99
|
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
99
100
|
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
@@ -101,6 +102,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
101
102
|
|
|
102
103
|
```bash
|
|
103
104
|
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
105
|
+
$ lithe run --shell "检查当前目录的项目状态"
|
|
104
106
|
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
105
107
|
```
|
|
106
108
|
|
|
@@ -8,9 +8,9 @@ Everything here maps lithe's host contract onto CLI defaults:
|
|
|
8
8
|
kernel's zero-database default;
|
|
9
9
|
- tools — the workspace bundle (read/write/edit/list/search/glob +
|
|
10
10
|
apply_patch) and todos, plus opt-in capabilities: ``--code`` (sandboxed
|
|
11
|
-
Python), ``--
|
|
12
|
-
probe/analysis), ``--download`` (SSRF-guarded
|
|
13
|
-
``--mcp`` (external MCP servers);
|
|
11
|
+
Python), ``--shell`` (native host commands), ``--skills`` (markdown skill
|
|
12
|
+
library), ``--vision`` (image probe/analysis), ``--download`` (SSRF-guarded
|
|
13
|
+
network fetch) and ``--mcp`` (external MCP servers);
|
|
14
14
|
- undo — the bundled tools register reverters, so ``lithe undo`` works with
|
|
15
15
|
zero configuration.
|
|
16
16
|
"""
|
|
@@ -32,6 +32,7 @@ from lithe.bundles import (
|
|
|
32
32
|
Workspace,
|
|
33
33
|
undo_run,
|
|
34
34
|
)
|
|
35
|
+
from lithe.bundles.command import register_command_tools
|
|
35
36
|
from lithe.bundles.download import register_download_tools
|
|
36
37
|
from lithe.bundles.patch import register_apply_patch_tool
|
|
37
38
|
from lithe.bundles.workspace import register_file_tools
|
|
@@ -57,6 +58,8 @@ def build_system_prompt(cfg: Config) -> str:
|
|
|
57
58
|
extras = []
|
|
58
59
|
if cfg.code:
|
|
59
60
|
extras.append("可以用 run_code 执行 Python 来验证想法、计算或测试。")
|
|
61
|
+
if cfg.shell:
|
|
62
|
+
extras.append("可以用 run_command 在主机上运行系统命令;命令有当前用户权限且副作用不可撤销,执行前先确认必要性。")
|
|
60
63
|
if cfg.download:
|
|
61
64
|
extras.append("可以用 download_file 下载网络文件到工作区。")
|
|
62
65
|
if cfg.skills_dir is not None:
|
|
@@ -97,6 +100,12 @@ def build_registry(cfg: Config) -> ToolRegistry:
|
|
|
97
100
|
|
|
98
101
|
runner = CodeRunner(sys.executable, backend=sandbox_backend())
|
|
99
102
|
register_code_tools(reg, lambda ctx: str(cfg.workspace_dir.resolve()), runner)
|
|
103
|
+
if cfg.shell:
|
|
104
|
+
from lithe.bundles.command import CommandRunner
|
|
105
|
+
|
|
106
|
+
register_command_tools(
|
|
107
|
+
reg, lambda ctx: cfg.workspace_dir.resolve(), CommandRunner()
|
|
108
|
+
)
|
|
100
109
|
if cfg.skills_dir is not None:
|
|
101
110
|
from lithe.bundles.skills import SkillLibrary, register_skill_tool
|
|
102
111
|
|
|
@@ -146,14 +155,14 @@ def render_event(
|
|
|
146
155
|
ui.assistant(text)
|
|
147
156
|
elif t == "tool_call":
|
|
148
157
|
args = json.dumps(ev.get("args", {}), ensure_ascii=False)
|
|
149
|
-
ui.tool_call(str(ev.get("name")), args
|
|
158
|
+
ui.tool_call(str(ev.get("name")), args)
|
|
150
159
|
if ev.get("id") is not None:
|
|
151
160
|
pending[ev["id"]] = time.monotonic()
|
|
152
161
|
elif t == "tool_result":
|
|
153
162
|
summary = (ev.get("summary") or "").replace("\n", " ")
|
|
154
163
|
started = pending.pop(ev.get("id"), None) if ev.get("id") else None
|
|
155
164
|
elapsed = (time.monotonic() - started) if started is not None else None
|
|
156
|
-
ui.tool_result(bool(ev.get("ok")), summary
|
|
165
|
+
ui.tool_result(bool(ev.get("ok")), summary, elapsed)
|
|
157
166
|
elif t == "error":
|
|
158
167
|
ui.error(str(ev.get("message", "")))
|
|
159
168
|
elif t == "cancelled":
|
|
@@ -57,6 +57,7 @@ class Config:
|
|
|
57
57
|
download: bool = False
|
|
58
58
|
verbose: bool = False
|
|
59
59
|
code: bool = False
|
|
60
|
+
shell: bool = False
|
|
60
61
|
skills_dir: Path | None = None
|
|
61
62
|
mcp_spec: str | None = None
|
|
62
63
|
mcp_servers: list = field(default_factory=list)
|
|
@@ -204,6 +205,7 @@ def load_config(args: Any) -> Config:
|
|
|
204
205
|
download=g("download", False),
|
|
205
206
|
verbose=g("verbose", False),
|
|
206
207
|
code=g("code", False),
|
|
208
|
+
shell=g("shell", False),
|
|
207
209
|
skills_dir=skills_dir,
|
|
208
210
|
mcp_spec=mcp_raw,
|
|
209
211
|
mcp_servers=load_mcp_spec(mcp_raw) if mcp_raw else [],
|
|
@@ -66,6 +66,11 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
66
66
|
help="grant run_code/run_file (sandboxed Python; "
|
|
67
67
|
"bwrap when available, passthrough otherwise)",
|
|
68
68
|
)
|
|
69
|
+
sp.add_argument(
|
|
70
|
+
"--shell",
|
|
71
|
+
action="store_true",
|
|
72
|
+
help="grant run_command (native shell with current-user host access)",
|
|
73
|
+
)
|
|
69
74
|
sp.add_argument(
|
|
70
75
|
"--skills",
|
|
71
76
|
metavar="DIR",
|
|
@@ -178,7 +183,7 @@ def _cmd_runs(cfg: Any, limit: int) -> int:
|
|
|
178
183
|
rows = []
|
|
179
184
|
for r in reversed(runs): # newest last, like a log
|
|
180
185
|
task = (r.task or "").replace("\n", " ")
|
|
181
|
-
rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", task
|
|
186
|
+
rows.append([r.run_id, r.status, str(r.steps), f"{r.cost:.4f}", truncate(task, 60)])
|
|
182
187
|
print(
|
|
183
188
|
ui.table(
|
|
184
189
|
["run", "status", "steps", "cost", "task"],
|
|
@@ -224,7 +229,7 @@ def _cmd_doctor() -> int:
|
|
|
224
229
|
class _Args: # doctor reuses load_config over env-only defaults
|
|
225
230
|
api_key = base_url = model = store = workspace = user = None
|
|
226
231
|
skills = mcp = None
|
|
227
|
-
download = code = vision = color = no_color = verbose = False
|
|
232
|
+
download = code = shell = vision = color = no_color = verbose = False
|
|
228
233
|
|
|
229
234
|
cfg = load_config(_Args())
|
|
230
235
|
print(ui.s(f"lithe-cli {__version__}(lithe {lithe_version})", CYAN, BOLD))
|
|
@@ -264,6 +269,8 @@ def _cmd_doctor() -> int:
|
|
|
264
269
|
else:
|
|
265
270
|
sandbox_line = ui.s("passthrough(未找到 bwrap,无隔离)", RED)
|
|
266
271
|
print(ui.kv("sandbox", sandbox_line + ";--code 启用 run_code"))
|
|
272
|
+
command_line = "已启用(run_command 可执行主机命令)" if cfg.shell else "未启用(--shell 显式授权)"
|
|
273
|
+
print(ui.kv("shell", command_line))
|
|
267
274
|
|
|
268
275
|
skills = default_skills_dir()
|
|
269
276
|
if cfg.skills_dir is not None:
|
|
@@ -13,6 +13,7 @@ from __future__ import annotations
|
|
|
13
13
|
|
|
14
14
|
import os
|
|
15
15
|
import re
|
|
16
|
+
import shutil
|
|
16
17
|
import sys
|
|
17
18
|
import unicodedata
|
|
18
19
|
|
|
@@ -81,8 +82,13 @@ def truncate(text: str, width: int) -> str:
|
|
|
81
82
|
class UI:
|
|
82
83
|
"""All terminal output goes through here; ``color`` gates every code."""
|
|
83
84
|
|
|
84
|
-
def __init__(self, color: bool = False):
|
|
85
|
+
def __init__(self, color: bool = False, width: int | None = None):
|
|
85
86
|
self.color = bool(color)
|
|
87
|
+
self._width = width
|
|
88
|
+
|
|
89
|
+
@property
|
|
90
|
+
def width(self) -> int:
|
|
91
|
+
return max(12, self._width or shutil.get_terminal_size((100, 24)).columns)
|
|
86
92
|
|
|
87
93
|
def s(self, text: str, *styles: str) -> str:
|
|
88
94
|
"""Wrap *text* in *styles* when color is on and text is non-empty."""
|
|
@@ -93,36 +99,53 @@ class UI:
|
|
|
93
99
|
# -- structural ---------------------------------------------------------
|
|
94
100
|
|
|
95
101
|
def rule(self, char: str = "─", width: int = 60) -> str:
|
|
96
|
-
return self.s(char * width, DIM)
|
|
102
|
+
return self.s(char * min(width, self.width), DIM)
|
|
97
103
|
|
|
98
104
|
def kv(self, label: str, value: str) -> str:
|
|
99
105
|
return f"{self.s(pad(label, 11), CYAN, BOLD)} {value}"
|
|
100
106
|
|
|
101
107
|
def section(self, title: str) -> str:
|
|
102
|
-
|
|
103
|
-
|
|
108
|
+
width = min(50, self.width)
|
|
109
|
+
label = f"── {title} "
|
|
110
|
+
if display_width(label) > width:
|
|
111
|
+
return self.s(truncate(label, width), DIM)
|
|
112
|
+
return self.s(label + "─" * (width - display_width(label)), DIM)
|
|
104
113
|
|
|
105
114
|
def table(
|
|
106
115
|
self, headers: list[str], rows: list[list[str]], cell_styles: list | None = None
|
|
107
116
|
) -> str:
|
|
108
|
-
"""Aligned table
|
|
117
|
+
"""Aligned, terminal-width-aware table with display-width styling."""
|
|
109
118
|
widths = [display_width(h) for h in headers]
|
|
110
119
|
for row in rows:
|
|
111
|
-
for i, cell in enumerate(row):
|
|
120
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
112
121
|
widths[i] = max(widths[i], display_width(cell))
|
|
122
|
+
spacing = " " if self.width >= 3 * len(headers) else " "
|
|
123
|
+
available = max(1, self.width - display_width(spacing) * (len(headers) - 1))
|
|
124
|
+
overflow = max(0, sum(widths) - available)
|
|
125
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
126
|
+
floor = min(widths[i], max(1, display_width(headers[i])))
|
|
127
|
+
reduction = min(overflow, widths[i] - floor)
|
|
128
|
+
widths[i] -= reduction
|
|
129
|
+
overflow -= reduction
|
|
130
|
+
for i in range(len(widths) - 1, -1, -1):
|
|
131
|
+
reduction = min(overflow, widths[i] - 1)
|
|
132
|
+
widths[i] -= reduction
|
|
133
|
+
overflow -= reduction
|
|
134
|
+
shown_headers = [truncate(h, widths[i]) for i, h in enumerate(headers)]
|
|
113
135
|
head = self.s(
|
|
114
|
-
|
|
136
|
+
spacing.join(pad(shown_headers[i], widths[i]) for i in range(len(headers))),
|
|
137
|
+
BOLD,
|
|
138
|
+
DIM,
|
|
115
139
|
)
|
|
116
|
-
sep = self.s(
|
|
140
|
+
sep = self.s(spacing.join("─" * w for w in widths), DIM)
|
|
117
141
|
lines = [head, sep]
|
|
118
142
|
for row in rows:
|
|
119
143
|
cells = []
|
|
120
|
-
for i, cell in enumerate(row):
|
|
121
|
-
|
|
122
|
-
if cell_styles and cell_styles[i]
|
|
123
|
-
styled = cell_styles[i](cell)
|
|
144
|
+
for i, cell in enumerate(row[:len(headers)]):
|
|
145
|
+
shown = truncate(cell, widths[i])
|
|
146
|
+
styled = cell_styles[i](shown) if cell_styles and cell_styles[i] else shown
|
|
124
147
|
cells.append(pad(styled, widths[i]))
|
|
125
|
-
lines.append(
|
|
148
|
+
lines.append(spacing.join(cells))
|
|
126
149
|
return "\n".join(lines)
|
|
127
150
|
|
|
128
151
|
def status(self, text: str) -> str:
|
|
@@ -146,12 +169,15 @@ class UI:
|
|
|
146
169
|
print(self.s(text, GREEN))
|
|
147
170
|
|
|
148
171
|
def tool_call(self, name: str, args: str) -> None:
|
|
149
|
-
|
|
172
|
+
prefix = f" ⚒ {name} "
|
|
173
|
+
detail = truncate(args, max(8, self.width - display_width(prefix)))
|
|
174
|
+
print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(detail, DIM)}")
|
|
150
175
|
|
|
151
176
|
def tool_result(self, ok: bool, summary: str, elapsed: float | None) -> None:
|
|
152
177
|
mark = self.s("✓", GREEN) if ok else self.s("✗", RED)
|
|
153
178
|
tail = self.s(f" {elapsed:.1f}s", DIM) if elapsed is not None else ""
|
|
154
|
-
|
|
179
|
+
label = truncate(summary, max(8, self.width - 8 - display_width(tail)))
|
|
180
|
+
print(f" {mark} {self.s(label, GREEN if ok else RED)}{tail}")
|
|
155
181
|
|
|
156
182
|
def error(self, msg: str) -> None:
|
|
157
183
|
print(f" {self.s('!', RED)} {self.s(msg, RED)}")
|
|
@@ -188,15 +214,17 @@ class UI:
|
|
|
188
214
|
|
|
189
215
|
def banner(self, version: str, model: str, workspace: str) -> str:
|
|
190
216
|
rows = [
|
|
191
|
-
f"lithe {version}",
|
|
192
|
-
f"model
|
|
193
|
-
f"
|
|
194
|
-
"/help
|
|
217
|
+
f"lithe {version} · command agent",
|
|
218
|
+
f"◆ model {model}",
|
|
219
|
+
f"⌂ workspace {workspace}",
|
|
220
|
+
"/help 查看会话命令 · /exit 退出",
|
|
195
221
|
]
|
|
196
|
-
|
|
222
|
+
inner = max(8, self.width - 4)
|
|
223
|
+
rows = [truncate(row, inner - 2) for row in rows]
|
|
224
|
+
width = max(display_width(row) for row in rows) + 2
|
|
197
225
|
top = "╭" + "─" * width + "╮"
|
|
198
226
|
bot = "╰" + "─" * width + "╯"
|
|
199
|
-
body = "\n".join("│" + pad(" " +
|
|
227
|
+
body = "\n".join("│" + pad(" " + row, width) + "│" for row in rows)
|
|
200
228
|
art = "\n".join((top, body, bot))
|
|
201
229
|
return self.s(art, CYAN)
|
|
202
230
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.4
|
|
2
2
|
Name: lithe-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.4.1
|
|
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
|
|
|
@@ -39,7 +39,7 @@ Classifier: Typing :: Typed
|
|
|
39
39
|
Requires-Python: >=3.10
|
|
40
40
|
Description-Content-Type: text/markdown
|
|
41
41
|
License-File: LICENSE
|
|
42
|
-
Requires-Dist: lithe>=0.9.
|
|
42
|
+
Requires-Dist: lithe>=0.9.8
|
|
43
43
|
Requires-Dist: prompt-toolkit>=3.0.38
|
|
44
44
|
Provides-Extra: dev
|
|
45
45
|
Requires-Dist: pytest>=8; extra == "dev"
|
|
@@ -118,7 +118,7 @@ usage/context gauges; `--max-steps` caps the tool loop;
|
|
|
118
118
|
```bash
|
|
119
119
|
$ lithe chat
|
|
120
120
|
╭──────────────────────────────────────╮
|
|
121
|
-
│ lithe 0.
|
|
121
|
+
│ lithe 0.4.0 │
|
|
122
122
|
│ model your-model │
|
|
123
123
|
│ workdir /home/me/my-project │
|
|
124
124
|
│ /help 查看会话命令,/exit 退出 │
|
|
@@ -142,6 +142,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
142
142
|
| Flag | Tools granted | Notes |
|
|
143
143
|
| --- | --- | --- |
|
|
144
144
|
| `--code` | `run_code` / `run_file` | Python under bubblewrap when installed (passthrough otherwise); `doctor` shows which |
|
|
145
|
+
| `--shell` | `run_command` | Native bash/sh on Linux and macOS, PowerShell/cmd on Windows; runs with the current user's host permissions, is not sandboxed, and cannot be undone |
|
|
145
146
|
| `--skills DIR` | `load_skill` | markdown skill library; defaults to `$LITHE_HOME/skills` when it exists, `--skills ""` disables |
|
|
146
147
|
| `--download` | `download_file` | SSRF-guarded, size-capped network fetch |
|
|
147
148
|
| `--vision` | `image_info` / `analyze_image` | image probe is stdlib-only; analysis routes one vision call to the main endpoint |
|
|
@@ -149,6 +150,7 @@ The kernel ships these as bundles; the CLI grants them per flag:
|
|
|
149
150
|
|
|
150
151
|
```bash
|
|
151
152
|
$ lithe run --code "用 run_code 验证 results.csv 的行数"
|
|
153
|
+
$ lithe run --shell "检查当前目录的项目状态"
|
|
152
154
|
$ lithe chat --skills ~/my-skills --mcp @~/mcp.json
|
|
153
155
|
```
|
|
154
156
|
|
|
@@ -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.
|
|
12
|
+
dependencies = ["lithe>=0.9.8", "prompt-toolkit>=3.0.38"]
|
|
13
13
|
keywords = ["agent", "llm", "react", "cli", "tool-calling"]
|
|
14
14
|
classifiers = [
|
|
15
15
|
"Development Status :: 4 - Beta",
|
|
@@ -45,6 +45,7 @@ def test_tools_command(tmp_path, capsys):
|
|
|
45
45
|
assert name in out, f"{name} missing from tools listing"
|
|
46
46
|
# download stays opt-in
|
|
47
47
|
assert "download_file" not in out
|
|
48
|
+
assert "run_command" not in out
|
|
48
49
|
|
|
49
50
|
|
|
50
51
|
def test_tools_command_with_download(tmp_path, capsys):
|
|
@@ -64,6 +65,23 @@ def test_tools_command_with_download(tmp_path, capsys):
|
|
|
64
65
|
assert "download_file" in capsys.readouterr().out
|
|
65
66
|
|
|
66
67
|
|
|
68
|
+
def test_tools_command_with_shell(tmp_path, capsys):
|
|
69
|
+
from lithe_cli.main import main
|
|
70
|
+
|
|
71
|
+
rc = main(
|
|
72
|
+
[
|
|
73
|
+
"tools",
|
|
74
|
+
"--workspace",
|
|
75
|
+
str(tmp_path),
|
|
76
|
+
"--store",
|
|
77
|
+
str(tmp_path / "s"),
|
|
78
|
+
"--shell",
|
|
79
|
+
]
|
|
80
|
+
)
|
|
81
|
+
assert rc == 0
|
|
82
|
+
assert "run_command" in capsys.readouterr().out
|
|
83
|
+
|
|
84
|
+
|
|
67
85
|
def test_tools_command_with_code(tmp_path, capsys):
|
|
68
86
|
from lithe_cli.main import main
|
|
69
87
|
|
|
@@ -182,7 +200,9 @@ def test_system_prompt_mentions_capabilities(tmp_path):
|
|
|
182
200
|
|
|
183
201
|
base = build_system_prompt(Config())
|
|
184
202
|
with_code = build_system_prompt(Config(code=True))
|
|
203
|
+
with_shell = build_system_prompt(Config(shell=True))
|
|
185
204
|
assert "run_code" in with_code and "run_code" not in base
|
|
205
|
+
assert "run_command" in with_shell and "run_command" not in base
|
|
186
206
|
|
|
187
207
|
|
|
188
208
|
def test_ui_degrades_without_color():
|
|
@@ -200,11 +220,30 @@ def test_ui_table_aligns_wide_chars():
|
|
|
200
220
|
|
|
201
221
|
out = UI(color=False).table(["name", "desc"], [["abc", "说明"], ["de", "plain"]])
|
|
202
222
|
lines = out.splitlines()
|
|
203
|
-
# rows line up: the desc column starts at the same offset on every row
|
|
204
223
|
offs = {ln.index("说") if "说" in ln else ln.index("p") for ln in lines[2:]}
|
|
205
224
|
assert len(offs) == 1
|
|
206
225
|
|
|
207
226
|
|
|
227
|
+
def test_ui_table_fits_narrow_terminal():
|
|
228
|
+
from lithe_cli.ui import UI, display_width
|
|
229
|
+
|
|
230
|
+
out = UI(color=False, width=24).table(
|
|
231
|
+
["name", "status", "description"],
|
|
232
|
+
[["long-tool-name", "running", "工作区中的较长中文说明文本"]],
|
|
233
|
+
)
|
|
234
|
+
assert all(display_width(line) <= 24 for line in out.splitlines())
|
|
235
|
+
assert "…" in out
|
|
236
|
+
|
|
237
|
+
|
|
238
|
+
def test_ui_banner_fits_terminal_width():
|
|
239
|
+
from lithe_cli.ui import UI, display_width
|
|
240
|
+
|
|
241
|
+
out = UI(color=False, width=28).banner("0.3.0", "a-very-long-model-name", "/a/very/long/workspace/path")
|
|
242
|
+
assert all(display_width(line) <= 28 for line in out.splitlines())
|
|
243
|
+
assert "workspace" in out
|
|
244
|
+
assert "…" in out
|
|
245
|
+
|
|
246
|
+
|
|
208
247
|
def test_no_command_prints_help(capsys):
|
|
209
248
|
from lithe_cli.main import main
|
|
210
249
|
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|