lithe-cli 0.1.0__py3-none-any.whl
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/__init__.py +3 -0
- lithe_cli/__main__.py +7 -0
- lithe_cli/agent.py +123 -0
- lithe_cli/config.py +91 -0
- lithe_cli/main.py +201 -0
- lithe_cli/py.typed +0 -0
- lithe_cli-0.1.0.dist-info/METADATA +168 -0
- lithe_cli-0.1.0.dist-info/RECORD +12 -0
- lithe_cli-0.1.0.dist-info/WHEEL +5 -0
- lithe_cli-0.1.0.dist-info/entry_points.txt +3 -0
- lithe_cli-0.1.0.dist-info/licenses/LICENSE +21 -0
- lithe_cli-0.1.0.dist-info/top_level.txt +1 -0
lithe_cli/__init__.py
ADDED
lithe_cli/__main__.py
ADDED
lithe_cli/agent.py
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
"""Host wiring: tools, store, LLM config, and the event printer.
|
|
2
|
+
|
|
3
|
+
Everything here maps lithe's host contract onto CLI defaults:
|
|
4
|
+
|
|
5
|
+
- workspace — the sandboxed file root the agent reads/writes (``--workspace``,
|
|
6
|
+
default the current directory);
|
|
7
|
+
- store — ``JsonlRunStore`` under ``~/.lithe/runs`` (``--store``), the
|
|
8
|
+
kernel's zero-database default;
|
|
9
|
+
- tools — the workspace bundle (read/write/edit/list/search/glob +
|
|
10
|
+
apply_patch) and todos; ``download_file`` is opt-in (``--download``)
|
|
11
|
+
because it grants the agent network fetch access;
|
|
12
|
+
- undo — the bundled tools register reverters, so ``lithe undo`` works with
|
|
13
|
+
zero configuration.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import json
|
|
18
|
+
from typing import Any
|
|
19
|
+
|
|
20
|
+
from lithe import AgentContext, LLMConfig, ToolRegistry
|
|
21
|
+
from lithe.bundles import (
|
|
22
|
+
AgentHost, JsonlRunStore, JsonTodoStore, Workspace, undo_run,
|
|
23
|
+
)
|
|
24
|
+
from lithe.bundles.download import register_download_tools
|
|
25
|
+
from lithe.bundles.patch import register_apply_patch_tool
|
|
26
|
+
from lithe.bundles.workspace import register_file_tools
|
|
27
|
+
from lithe.bundles.todos import register_todo_tools
|
|
28
|
+
|
|
29
|
+
from .config import Config
|
|
30
|
+
|
|
31
|
+
SYSTEM_PROMPT = (
|
|
32
|
+
"你是运行在命令行里的助理,工作区是用户的当前目录。"
|
|
33
|
+
"用提供的文件工具完成任务(读写前先读、谨慎修改),"
|
|
34
|
+
"需要多步任务时先用 update_todos 列出计划,最后用简洁中文汇报结果。"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def build_registry(cfg: Config) -> ToolRegistry:
|
|
39
|
+
"""Register the CLI's tool set against the configured workspace."""
|
|
40
|
+
reg = ToolRegistry()
|
|
41
|
+
ws_root = cfg.workspace_dir.resolve()
|
|
42
|
+
workspace_for = lambda ctx: Workspace(ws_root) # noqa: E731
|
|
43
|
+
register_file_tools(reg, workspace_for)
|
|
44
|
+
register_apply_patch_tool(reg, workspace_for)
|
|
45
|
+
todo_path = cfg.store_dir / f"todos-{cfg.user_id}.json"
|
|
46
|
+
register_todo_tools(reg, lambda ctx: JsonTodoStore(todo_path))
|
|
47
|
+
if cfg.download:
|
|
48
|
+
register_download_tools(reg, workspace_for)
|
|
49
|
+
return reg
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def build_host(cfg: Config, reg: ToolRegistry) -> AgentHost:
|
|
53
|
+
"""Build the AgentHost; the store lands under cfg.store_dir."""
|
|
54
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
55
|
+
llm = LLMConfig(
|
|
56
|
+
model=cfg.model or "unused",
|
|
57
|
+
base_url=cfg.base_url or "unused",
|
|
58
|
+
api_key=cfg.api_key or "unused",
|
|
59
|
+
timeout=cfg.timeout,
|
|
60
|
+
attempts=cfg.attempts,
|
|
61
|
+
stream=cfg.stream,
|
|
62
|
+
context_window=cfg.context_window,
|
|
63
|
+
transport=cfg.transport,
|
|
64
|
+
)
|
|
65
|
+
return AgentHost(
|
|
66
|
+
reg, llm, store, max_steps=cfg.max_steps,
|
|
67
|
+
build_system_prompt=lambda ctx, mode, anchor: SYSTEM_PROMPT,
|
|
68
|
+
)
|
|
69
|
+
|
|
70
|
+
|
|
71
|
+
def print_event(ev: dict, verbose: bool = False) -> None:
|
|
72
|
+
"""Render one runtime event onto the terminal."""
|
|
73
|
+
t = ev.get("type")
|
|
74
|
+
if t == "assistant_delta":
|
|
75
|
+
print(ev.get("text", ""), end="", flush=True)
|
|
76
|
+
elif t == "assistant":
|
|
77
|
+
if ev.get("text"):
|
|
78
|
+
print(ev.get("text", ""))
|
|
79
|
+
elif t == "tool_call":
|
|
80
|
+
args = json.dumps(ev.get("args", {}), ensure_ascii=False)
|
|
81
|
+
print(f" ⚒ {ev.get('name')} {args[:120]}")
|
|
82
|
+
elif t == "tool_result":
|
|
83
|
+
summary = (ev.get("summary") or "").replace("\n", " ")
|
|
84
|
+
mark = "✓" if ev.get("ok") else "✗"
|
|
85
|
+
print(f" {mark} {summary[:160]}")
|
|
86
|
+
elif t == "error":
|
|
87
|
+
print(f" ! {ev.get('message')}")
|
|
88
|
+
elif verbose and t == "usage":
|
|
89
|
+
print(f" · tokens p{ev.get('prompt_tokens')}/c{ev.get('completion_tokens')}"
|
|
90
|
+
f" ctx={ev.get('context_tokens') or '?'}tk"
|
|
91
|
+
f" ({ev.get('context_percent')}%)")
|
|
92
|
+
elif verbose and t == "reasoning":
|
|
93
|
+
digest = (ev.get("summary") or "").replace("\n", " ")
|
|
94
|
+
if digest:
|
|
95
|
+
print(f" ~ {digest[:120]}")
|
|
96
|
+
|
|
97
|
+
|
|
98
|
+
async def execute(
|
|
99
|
+
cfg: Config, task: str, history: list[dict] | None = None,
|
|
100
|
+
run_id: str | None = None,
|
|
101
|
+
) -> tuple[str, dict, Any]:
|
|
102
|
+
"""Run one task end-to-end; returns (run_id, done_event, host)."""
|
|
103
|
+
import uuid
|
|
104
|
+
|
|
105
|
+
reg = build_registry(cfg)
|
|
106
|
+
host = build_host(cfg, reg)
|
|
107
|
+
rid = run_id or uuid.uuid4().hex[:12]
|
|
108
|
+
ctx = AgentContext(run_id=rid, user_id=cfg.user_id)
|
|
109
|
+
ev: dict = {}
|
|
110
|
+
async for ev in host.run(ctx, task, history=history):
|
|
111
|
+
print_event(ev, cfg.verbose)
|
|
112
|
+
if cfg.stream:
|
|
113
|
+
print() # close the streaming line
|
|
114
|
+
return rid, ev, host
|
|
115
|
+
|
|
116
|
+
|
|
117
|
+
async def undo(cfg: Config, run_id: str) -> int:
|
|
118
|
+
"""Revert a run's mutations with the bundled tools' reverters."""
|
|
119
|
+
reg = build_registry(cfg)
|
|
120
|
+
host = build_host(cfg, reg)
|
|
121
|
+
report = await undo_run(host, run_id, cfg.user_id)
|
|
122
|
+
print(f"已撤销 {report.reverted} 个操作(run {run_id})")
|
|
123
|
+
return 0
|
lithe_cli/config.py
ADDED
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
"""Configuration resolved from CLI flags and the environment.
|
|
2
|
+
|
|
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.
|
|
7
|
+
"""
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import os
|
|
11
|
+
from dataclasses import dataclass, field
|
|
12
|
+
from pathlib import Path
|
|
13
|
+
from typing import Any
|
|
14
|
+
|
|
15
|
+
ENV_API_KEY = "LITHE_API_KEY"
|
|
16
|
+
ENV_BASE_URL = "LITHE_BASE_URL"
|
|
17
|
+
ENV_MODEL = "LITHE_MODEL"
|
|
18
|
+
ENV_HOME = "LITHE_HOME"
|
|
19
|
+
|
|
20
|
+
DEFAULT_USER = "cli"
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class Config:
|
|
25
|
+
"""Everything one CLI invocation needs (endpoint, store, workspace)."""
|
|
26
|
+
|
|
27
|
+
api_key: str | None = None
|
|
28
|
+
base_url: str | None = None
|
|
29
|
+
model: str | None = None
|
|
30
|
+
store_dir: Path = field(default_factory=lambda: default_store_dir())
|
|
31
|
+
workspace_dir: Path = field(default_factory=lambda: Path.cwd())
|
|
32
|
+
user_id: str = DEFAULT_USER
|
|
33
|
+
stream: bool = False
|
|
34
|
+
max_steps: int = 35
|
|
35
|
+
context_window: int | None = None
|
|
36
|
+
timeout: float = 180.0
|
|
37
|
+
attempts: int = 2
|
|
38
|
+
download: bool = False
|
|
39
|
+
verbose: bool = False
|
|
40
|
+
# Wire protocol ("chat" | "responses") or a custom LLMTransport instance.
|
|
41
|
+
# Not exposed as a flag: the injection point for offline tests.
|
|
42
|
+
transport: Any = "chat"
|
|
43
|
+
|
|
44
|
+
@property
|
|
45
|
+
def has_endpoint(self) -> bool:
|
|
46
|
+
if not isinstance(self.transport, str):
|
|
47
|
+
return True # scripted/custom transport needs no endpoint
|
|
48
|
+
return bool(self.api_key and self.base_url and self.model)
|
|
49
|
+
|
|
50
|
+
|
|
51
|
+
def default_store_dir() -> Path:
|
|
52
|
+
home = Path(os.environ.get(ENV_HOME, "~/.lithe")).expanduser()
|
|
53
|
+
return home / "runs"
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def load_config(args: Any) -> Config:
|
|
57
|
+
"""Merge parsed argparse flags over environment defaults."""
|
|
58
|
+
store_dir = Path(args.store).expanduser() if args.store else default_store_dir()
|
|
59
|
+
workspace_dir = (Path(args.workspace).expanduser()
|
|
60
|
+
if args.workspace else Path.cwd())
|
|
61
|
+
# Capability flags only run/chat define; getattr keeps tools/runs/log/undo
|
|
62
|
+
# parsers minimal while sharing one Config shape.
|
|
63
|
+
g = lambda name, default: getattr(args, name, default) # noqa: E731
|
|
64
|
+
return Config(
|
|
65
|
+
api_key=args.api_key or os.environ.get(ENV_API_KEY),
|
|
66
|
+
base_url=args.base_url or os.environ.get(ENV_BASE_URL),
|
|
67
|
+
model=args.model or os.environ.get(ENV_MODEL),
|
|
68
|
+
store_dir=store_dir,
|
|
69
|
+
workspace_dir=workspace_dir,
|
|
70
|
+
user_id=args.user or DEFAULT_USER,
|
|
71
|
+
stream=g("stream", False),
|
|
72
|
+
max_steps=g("max_steps", 35),
|
|
73
|
+
context_window=g("context_window", None),
|
|
74
|
+
timeout=g("timeout", 180.0),
|
|
75
|
+
attempts=g("attempts", 2),
|
|
76
|
+
download=g("download", False),
|
|
77
|
+
verbose=args.verbose,
|
|
78
|
+
)
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
def require_endpoint(cfg: Config) -> None:
|
|
82
|
+
"""Exit with a how-to message when no endpoint is configured."""
|
|
83
|
+
if cfg.has_endpoint:
|
|
84
|
+
return
|
|
85
|
+
raise SystemExit(
|
|
86
|
+
"缺少模型端点配置。请设置环境变量(或使用对应flag):\n"
|
|
87
|
+
f" export {ENV_API_KEY}=sk-...\n"
|
|
88
|
+
f" export {ENV_BASE_URL}=https://your-endpoint/api/v1\n"
|
|
89
|
+
f" export {ENV_MODEL}=your-model\n"
|
|
90
|
+
"本 CLI 不内置任何默认端点。"
|
|
91
|
+
)
|
lithe_cli/main.py
ADDED
|
@@ -0,0 +1,201 @@
|
|
|
1
|
+
"""The `lithe` command-line entry point.
|
|
2
|
+
|
|
3
|
+
Subcommands:
|
|
4
|
+
run TASK one-shot task against the configured endpoint
|
|
5
|
+
chat interactive session (history carried across turns)
|
|
6
|
+
tools list the tools this CLI registers
|
|
7
|
+
runs list stored runs
|
|
8
|
+
log RUN_ID show a stored run's messages and actions
|
|
9
|
+
undo RUN_ID revert a run's file/todo mutations
|
|
10
|
+
|
|
11
|
+
Endpoint config: LITHE_API_KEY / LITHE_BASE_URL / LITHE_MODEL (flags
|
|
12
|
+
override). Storage lands in ~/.lithe/runs (LITHE_HOME to move it); the
|
|
13
|
+
agent's workspace is the current directory unless --workspace says else.
|
|
14
|
+
"""
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
import argparse
|
|
18
|
+
import asyncio
|
|
19
|
+
import sys
|
|
20
|
+
from typing import Any
|
|
21
|
+
|
|
22
|
+
from lithe import __version__ as lithe_version
|
|
23
|
+
from lithe.bundles import JsonlRunStore
|
|
24
|
+
|
|
25
|
+
from . import __version__
|
|
26
|
+
from .agent import build_registry, execute, undo
|
|
27
|
+
from .config import load_config, require_endpoint
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def build_parser() -> argparse.ArgumentParser:
|
|
31
|
+
p = argparse.ArgumentParser(
|
|
32
|
+
prog="lithe",
|
|
33
|
+
description="Command-line interface for the lithe agent kernel.",
|
|
34
|
+
)
|
|
35
|
+
p.add_argument("--version", action="version",
|
|
36
|
+
version=f"lithe-cli {__version__} (lithe {lithe_version})")
|
|
37
|
+
sub = p.add_subparsers(dest="command")
|
|
38
|
+
|
|
39
|
+
def common(sp: argparse.ArgumentParser) -> None:
|
|
40
|
+
sp.add_argument("--api-key", help="endpoint API key (env LITHE_API_KEY)")
|
|
41
|
+
sp.add_argument("--base-url", help="endpoint base URL (env LITHE_BASE_URL)")
|
|
42
|
+
sp.add_argument("--model", help="model name (env LITHE_MODEL)")
|
|
43
|
+
sp.add_argument("--workspace", help="agent workspace dir (default: cwd)")
|
|
44
|
+
sp.add_argument("--store", help="run store dir (default: ~/.lithe/runs)")
|
|
45
|
+
sp.add_argument("--user", help="user id for the store (default: cli)")
|
|
46
|
+
sp.add_argument("--download", action="store_true",
|
|
47
|
+
help="grant the agent download_file (network fetch)")
|
|
48
|
+
sp.add_argument("--verbose", "-v", action="store_true",
|
|
49
|
+
help="show usage/reasoning events")
|
|
50
|
+
|
|
51
|
+
def runtime(sp: argparse.ArgumentParser) -> None:
|
|
52
|
+
common(sp)
|
|
53
|
+
sp.add_argument("--stream", action="store_true",
|
|
54
|
+
help="stream tokens as they generate")
|
|
55
|
+
sp.add_argument("--max-steps", type=int, default=35,
|
|
56
|
+
help="tool-loop step budget (default: 35)")
|
|
57
|
+
sp.add_argument("--context-window", type=int, default=None,
|
|
58
|
+
help="model context window, for fullness gauges")
|
|
59
|
+
sp.add_argument("--timeout", type=float, default=180.0,
|
|
60
|
+
help="per-call timeout in seconds")
|
|
61
|
+
sp.add_argument("--attempts", type=int, default=2,
|
|
62
|
+
help="per-call retry attempts")
|
|
63
|
+
|
|
64
|
+
run_p = sub.add_parser("run", help="run one task")
|
|
65
|
+
runtime(run_p)
|
|
66
|
+
run_p.add_argument("task", help="the task for the agent")
|
|
67
|
+
|
|
68
|
+
chat_p = sub.add_parser("chat", help="interactive session")
|
|
69
|
+
runtime(chat_p)
|
|
70
|
+
|
|
71
|
+
tools_p = sub.add_parser("tools", help="list registered tools")
|
|
72
|
+
common(tools_p)
|
|
73
|
+
|
|
74
|
+
runs_p = sub.add_parser("runs", help="list stored runs")
|
|
75
|
+
common(runs_p)
|
|
76
|
+
runs_p.add_argument("--limit", type=int, default=30)
|
|
77
|
+
|
|
78
|
+
log_p = sub.add_parser("log", help="show a run's messages and actions")
|
|
79
|
+
common(log_p)
|
|
80
|
+
log_p.add_argument("run_id")
|
|
81
|
+
|
|
82
|
+
undo_p = sub.add_parser("undo", help="revert a run's mutations")
|
|
83
|
+
common(undo_p)
|
|
84
|
+
undo_p.add_argument("run_id")
|
|
85
|
+
|
|
86
|
+
return p
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _cmd_tools(cfg: Any) -> int:
|
|
90
|
+
reg = build_registry(cfg)
|
|
91
|
+
for name in sorted(reg.names()):
|
|
92
|
+
spec = reg.spec(name)
|
|
93
|
+
cat = spec.category.name if spec.category is not None else "?"
|
|
94
|
+
desc = (spec.description or "").splitlines()[0]
|
|
95
|
+
print(f"{name:<16} {cat:<6} {desc}")
|
|
96
|
+
return 0
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _cmd_runs(cfg: Any, limit: int) -> int:
|
|
100
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
101
|
+
runs = store.list_runs(cfg.user_id, limit=limit)
|
|
102
|
+
if not runs:
|
|
103
|
+
print(f"({cfg.store_dir} 下没有 run 记录)")
|
|
104
|
+
return 0
|
|
105
|
+
for r in reversed(runs): # newest last, like a log
|
|
106
|
+
task = (r.task or "").replace("\n", " ")
|
|
107
|
+
print(f"{r.run_id:<14} {r.status:<12} steps={r.steps:<3} "
|
|
108
|
+
f"cost={r.cost:<8.4f} {task[:60]}")
|
|
109
|
+
return 0
|
|
110
|
+
|
|
111
|
+
|
|
112
|
+
def _cmd_log(cfg: Any, run_id: str) -> int:
|
|
113
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
114
|
+
run = store.get_run(run_id, cfg.user_id)
|
|
115
|
+
if run is None:
|
|
116
|
+
print(f"找不到 run {run_id}(user={cfg.user_id})")
|
|
117
|
+
return 1
|
|
118
|
+
print(f"run {run.run_id} status={run.status} steps={run.steps} "
|
|
119
|
+
f"model={run.model}")
|
|
120
|
+
print(f"task: {run.task}")
|
|
121
|
+
if run.final:
|
|
122
|
+
print(f"final: {run.final[:500]}")
|
|
123
|
+
print("-- messages --")
|
|
124
|
+
for row in store.messages_for_run(run_id, cfg.user_id):
|
|
125
|
+
role = row.get("role", "?")
|
|
126
|
+
content = row.get("content") or ""
|
|
127
|
+
calls = row.get("tool_calls")
|
|
128
|
+
extra = f" tool_calls={len(calls)}" if calls else ""
|
|
129
|
+
print(f"[{role}] {str(content)[:300]}{extra}")
|
|
130
|
+
print("-- actions --")
|
|
131
|
+
actions = store.list_actions(run_id, cfg.user_id)
|
|
132
|
+
if not actions:
|
|
133
|
+
print("(无)")
|
|
134
|
+
for a in actions:
|
|
135
|
+
print(f"#{a.id} {a.kind:<14} {a.target:<40} {a.status}")
|
|
136
|
+
return 0
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _cmd_run(cfg: Any, task: str) -> int:
|
|
140
|
+
require_endpoint(cfg)
|
|
141
|
+
_, done, _ = asyncio.run(execute(cfg, task))
|
|
142
|
+
return 0 if done.get("status") == "done" else 1
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def _cmd_chat(cfg: Any) -> int:
|
|
146
|
+
require_endpoint(cfg)
|
|
147
|
+
return _chat_loop(cfg)
|
|
148
|
+
|
|
149
|
+
|
|
150
|
+
def _chat_loop(cfg: Any) -> int:
|
|
151
|
+
store = JsonlRunStore(cfg.store_dir)
|
|
152
|
+
run_ids: list[str] = []
|
|
153
|
+
print(f"lithe {__version__} — /exit 退出,输入任务开始。")
|
|
154
|
+
while True:
|
|
155
|
+
try:
|
|
156
|
+
line = input("lithe> ").strip()
|
|
157
|
+
except (EOFError, KeyboardInterrupt):
|
|
158
|
+
print()
|
|
159
|
+
break
|
|
160
|
+
if not line:
|
|
161
|
+
continue
|
|
162
|
+
if line in ("/exit", "/quit", "exit", "quit"):
|
|
163
|
+
break
|
|
164
|
+
history = (store.messages_for_runs(run_ids, cfg.user_id)
|
|
165
|
+
if run_ids else None)
|
|
166
|
+
try:
|
|
167
|
+
# One asyncio.run per turn: each run is self-contained, and the
|
|
168
|
+
# blocking input() stays out of async context.
|
|
169
|
+
rid, done, _ = asyncio.run(execute(cfg, line, history=history))
|
|
170
|
+
except KeyboardInterrupt:
|
|
171
|
+
print("\n(已中断本轮;输入继续)")
|
|
172
|
+
continue
|
|
173
|
+
run_ids.append(rid)
|
|
174
|
+
if done.get("status") != "done":
|
|
175
|
+
print(f"(本轮状态:{done.get('status')})")
|
|
176
|
+
return 0
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
def main(argv: list[str] | None = None) -> int:
|
|
180
|
+
args = build_parser().parse_args(argv)
|
|
181
|
+
if args.command is None:
|
|
182
|
+
build_parser().print_help()
|
|
183
|
+
return 2
|
|
184
|
+
cfg = load_config(args)
|
|
185
|
+
if args.command == "run":
|
|
186
|
+
return _cmd_run(cfg, args.task)
|
|
187
|
+
if args.command == "chat":
|
|
188
|
+
return _cmd_chat(cfg)
|
|
189
|
+
if args.command == "tools":
|
|
190
|
+
return _cmd_tools(cfg)
|
|
191
|
+
if args.command == "runs":
|
|
192
|
+
return _cmd_runs(cfg, args.limit)
|
|
193
|
+
if args.command == "log":
|
|
194
|
+
return _cmd_log(cfg, args.run_id)
|
|
195
|
+
if args.command == "undo":
|
|
196
|
+
return asyncio.run(undo(cfg, args.run_id))
|
|
197
|
+
return 2
|
|
198
|
+
|
|
199
|
+
|
|
200
|
+
if __name__ == "__main__":
|
|
201
|
+
sys.exit(main())
|
lithe_cli/py.typed
ADDED
|
File without changes
|
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: lithe-cli
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Command-line interface for the lithe agent kernel: chat, one-shot runs, undo, and run inspection.
|
|
5
|
+
License: MIT License
|
|
6
|
+
|
|
7
|
+
Copyright (c) 2026 betaloop contributors
|
|
8
|
+
|
|
9
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
10
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
11
|
+
in the Software without restriction, including without limitation the rights
|
|
12
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
13
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
14
|
+
furnished to do so, subject to the following conditions:
|
|
15
|
+
|
|
16
|
+
The above copyright notice and this permission notice shall be included in all
|
|
17
|
+
copies or substantial portions of the Software.
|
|
18
|
+
|
|
19
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
20
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
21
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
22
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
23
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
24
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
25
|
+
SOFTWARE.
|
|
26
|
+
|
|
27
|
+
Keywords: agent,llm,react,cli,tool-calling
|
|
28
|
+
Classifier: Development Status :: 4 - Beta
|
|
29
|
+
Classifier: Environment :: Console
|
|
30
|
+
Classifier: Intended Audience :: Developers
|
|
31
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
32
|
+
Classifier: Programming Language :: Python :: 3
|
|
33
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
34
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
35
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
36
|
+
Classifier: Programming Language :: Python :: 3.13
|
|
37
|
+
Classifier: Topic :: Software Development :: Libraries :: Application Frameworks
|
|
38
|
+
Classifier: Typing :: Typed
|
|
39
|
+
Requires-Python: >=3.10
|
|
40
|
+
Description-Content-Type: text/markdown
|
|
41
|
+
License-File: LICENSE
|
|
42
|
+
Requires-Dist: lithe>=0.9.5
|
|
43
|
+
Provides-Extra: dev
|
|
44
|
+
Requires-Dist: pytest>=8; extra == "dev"
|
|
45
|
+
Requires-Dist: ruff>=0.4; extra == "dev"
|
|
46
|
+
Dynamic: license-file
|
|
47
|
+
|
|
48
|
+
# lithe-cli
|
|
49
|
+
|
|
50
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
51
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
52
|
+
[](https://pypi.org/project/lithe-cli/)
|
|
53
|
+
|
|
54
|
+
**lithe-cli** is the command-line interface for
|
|
55
|
+
[lithe](https://pypi.org/project/lithe/) — the storage-free ReAct agent
|
|
56
|
+
kernel. Installing it pulls in `lithe` automatically and gives you a `lithe`
|
|
57
|
+
command: chat with an agent that reads and writes files in your workspace,
|
|
58
|
+
run one-shot tasks, inspect stored runs, and undo a run's mutations.
|
|
59
|
+
|
|
60
|
+
```bash
|
|
61
|
+
pip install lithe-cli
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Configure
|
|
65
|
+
|
|
66
|
+
There is no default endpoint — the CLI refuses to run rather than silently
|
|
67
|
+
hitting some third-party URL. Point it at any OpenAI-compatible endpoint:
|
|
68
|
+
|
|
69
|
+
```bash
|
|
70
|
+
export LITHE_API_KEY=sk-...
|
|
71
|
+
export LITHE_BASE_URL=https://your-endpoint/api/v1
|
|
72
|
+
export LITHE_MODEL=your-model
|
|
73
|
+
```
|
|
74
|
+
|
|
75
|
+
Flags (`--api-key`, `--base-url`, `--model`) override the environment.
|
|
76
|
+
`LITHE_HOME` (default `~/.lithe`) locates the run store; the agent's
|
|
77
|
+
workspace defaults to the current directory (`--workspace` to change).
|
|
78
|
+
|
|
79
|
+
## Use
|
|
80
|
+
|
|
81
|
+
### One-shot task
|
|
82
|
+
|
|
83
|
+
```bash
|
|
84
|
+
$ cd my-project
|
|
85
|
+
$ lithe run "总结 README.md 的要点,存到 SUMMARY.md"
|
|
86
|
+
⚒ read_file {"path": "README.md"}
|
|
87
|
+
✓ 已读取 README.md(120 行)
|
|
88
|
+
⚒ write_file {"path": "SUMMARY.md", "content": "..."}
|
|
89
|
+
✓ 写入 SUMMARY.md
|
|
90
|
+
已把要点写入 SUMMARY.md。
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
`--stream` streams tokens as they generate; `-v` adds per-call
|
|
94
|
+
usage/context gauges; `--max-steps` caps the tool loop;
|
|
95
|
+
`--context-window` enables fullness gauges.
|
|
96
|
+
|
|
97
|
+
### Interactive chat
|
|
98
|
+
|
|
99
|
+
```bash
|
|
100
|
+
$ lithe chat
|
|
101
|
+
lithe 0.1.0 — /exit 退出,输入任务开始。
|
|
102
|
+
lithe> 把 a.txt 改成三行待办清单
|
|
103
|
+
⚒ write_file {"path": "a.txt", ...}
|
|
104
|
+
✓ 写入 a.txt
|
|
105
|
+
已完成。
|
|
106
|
+
lithe> /exit
|
|
107
|
+
```
|
|
108
|
+
|
|
109
|
+
History carries across turns within a session (each turn is its own run in
|
|
110
|
+
the store, replayed as context for the next).
|
|
111
|
+
|
|
112
|
+
### Undo a run
|
|
113
|
+
|
|
114
|
+
The bundled file and todo tools register reverters, so undo works with zero
|
|
115
|
+
configuration:
|
|
116
|
+
|
|
117
|
+
```bash
|
|
118
|
+
$ lithe runs # find the run
|
|
119
|
+
abc123def456 done steps=3 ...
|
|
120
|
+
$ lithe undo abc123def456
|
|
121
|
+
已撤销 2 个操作(run abc123def456)
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
A file the run created is deleted; a file it overwrote is restored.
|
|
125
|
+
|
|
126
|
+
### Inspect
|
|
127
|
+
|
|
128
|
+
```bash
|
|
129
|
+
$ lithe runs # stored runs (newest last)
|
|
130
|
+
$ lithe log abc123def456 # messages + actions of one run
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
### Tools
|
|
134
|
+
|
|
135
|
+
```bash
|
|
136
|
+
$ lithe tools # what the agent can do
|
|
137
|
+
apply_patch WRITE 以行级 patch 一次修改多个文件...
|
|
138
|
+
edit_file WRITE 局部替换文件中的文本...
|
|
139
|
+
read_file READ 读取文件内容...
|
|
140
|
+
...
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
The default tool set is the workspace bundle (`read_file` / `write_file` /
|
|
144
|
+
`edit_file` / `list_files` / `search_files` / `glob_files` /
|
|
145
|
+
`apply_patch`) plus todos (`update_todos` / `list_todos`).
|
|
146
|
+
`--download` additionally grants `download_file` — the SSRF-guarded,
|
|
147
|
+
size-capped network fetch — for runs and chats.
|
|
148
|
+
|
|
149
|
+
## Where things land
|
|
150
|
+
|
|
151
|
+
| Path | Contents |
|
|
152
|
+
| --- | --- |
|
|
153
|
+
| current dir (or `--workspace`) | the agent's sandboxed workspace — every tool path resolves strictly inside it |
|
|
154
|
+
| `~/.lithe/runs` (or `--store`) | JSONL run store: messages, actions, undo records |
|
|
155
|
+
| `~/.lithe/runs/todos-<user>.json` | the agent's task list |
|
|
156
|
+
|
|
157
|
+
## Design notes
|
|
158
|
+
|
|
159
|
+
- The CLI is a thin host: it supplies tools, a system prompt, and the
|
|
160
|
+
kernel's `JsonlRunStore`; everything else (ReAct loop, streaming, budgets,
|
|
161
|
+
replay, undo engine) is reused from lithe.
|
|
162
|
+
- End-to-end behavior is tested offline against a scripted transport — no
|
|
163
|
+
test spends tokens.
|
|
164
|
+
- `python -m lithe_cli` works alongside the `lithe` console script.
|
|
165
|
+
|
|
166
|
+
## License
|
|
167
|
+
|
|
168
|
+
MIT
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
lithe_cli/__init__.py,sha256=q1xPbNFxhW5ddYQe-uaaDxqRlBmmyAr0I3hoWZ2T9Nc,95
|
|
2
|
+
lithe_cli/__main__.py,sha256=i_VzFEuvHPLVcXcqC1UBy_7nfk0H86nyJLvTB3yF_0w,152
|
|
3
|
+
lithe_cli/agent.py,sha256=AXWPDbRzNqm-AkgaAXLrkxgqCkd6BUlMmLIyR1u0AJM,4615
|
|
4
|
+
lithe_cli/config.py,sha256=KlhI2HRtqGcCiNGPbOFVUeaxrginz9IQPEz7tduhMMs,3275
|
|
5
|
+
lithe_cli/main.py,sha256=s7TtMHyQ40U7HyJzePwREPGYJTEVr5w3imwSQ6NS_No,7226
|
|
6
|
+
lithe_cli/py.typed,sha256=47DEQpj8HBSa-_TImW-5JCeuQeRkm5NMpJWZG3hSuFU,0
|
|
7
|
+
lithe_cli-0.1.0.dist-info/licenses/LICENSE,sha256=MLG-37iuP9mqLJOtdaLDjt-_hTW-rKE4CLQaCz_215c,1078
|
|
8
|
+
lithe_cli-0.1.0.dist-info/METADATA,sha256=cbw3SaEaX3wc07viP8aXog6ig2KE5h563tTb-41GlKU,6031
|
|
9
|
+
lithe_cli-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
|
|
10
|
+
lithe_cli-0.1.0.dist-info/entry_points.txt,sha256=HzDcc8FMDzt1FbPppIi21MgGAy2f8tJqnpmFiiQzDGw,78
|
|
11
|
+
lithe_cli-0.1.0.dist-info/top_level.txt,sha256=Zep9WGTghjOTFQJvKKLf3w7zy1E_ZUgmUbXb8gPqOD8,10
|
|
12
|
+
lithe_cli-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 betaloop contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
lithe_cli
|