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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: lithe-cli
3
- Version: 0.3.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.5
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.2.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.2.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
 
@@ -1,3 +1,3 @@
1
1
  """lithe-cli: the command-line interface for the lithe agent kernel."""
2
2
 
3
- __version__ = "0.3.0"
3
+ __version__ = "0.4.1"
@@ -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), ``--skills`` (markdown skill library), ``--vision`` (image
12
- probe/analysis), ``--download`` (SSRF-guarded network fetch) and
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[:120])
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[:160], elapsed)
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[:60]])
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
- tail = "─" * max(0, 50 - display_width(title))
103
- return self.s(f"── {title} {tail}", DIM)
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; ``cell_styles[i](text) -> str`` styles column i."""
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
- " ".join(pad(h, widths[i]) for i, h in enumerate(headers)), BOLD, DIM
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(" ".join("─" * w for w in widths), DIM)
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
- styled = cell
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(" ".join(cells))
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
- print(f" {self.s('⚒', CYAN)} {self.s(name, CYAN, BOLD)} {self.s(args, DIM)}")
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
- print(f" {mark} {self.s(summary, GREEN if ok else RED)}{tail}")
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 {model}",
193
- f"workdir {workspace}",
194
- "/help 查看会话命令,/exit 退出",
217
+ f"lithe {version} · command agent",
218
+ f"◆ model {model}",
219
+ f"⌂ workspace {workspace}",
220
+ "/help 查看会话命令 · /exit 退出",
195
221
  ]
196
- width = max(display_width(r) for r in rows) + 2
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(" " + r, width) + "│" for r in rows)
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.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.5
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.2.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
 
@@ -1,4 +1,4 @@
1
- lithe>=0.9.5
1
+ lithe>=0.9.8
2
2
  prompt-toolkit>=3.0.38
3
3
 
4
4
  [dev]
@@ -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", "prompt-toolkit>=3.0.38"]
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