lcode-cli 0.4.0__py3-none-any.whl → 0.5.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.
- lcode/__init__.py +1 -1
- lcode/agent.py +18 -0
- lcode/cli.py +24 -0
- lcode/config.py +10 -0
- lcode/repl.py +33 -0
- lcode/sandbox.py +204 -0
- lcode/tools.py +60 -26
- {lcode_cli-0.4.0.dist-info → lcode_cli-0.5.0.dist-info}/METADATA +11 -2
- {lcode_cli-0.4.0.dist-info → lcode_cli-0.5.0.dist-info}/RECORD +12 -11
- {lcode_cli-0.4.0.dist-info → lcode_cli-0.5.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.4.0.dist-info → lcode_cli-0.5.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.4.0.dist-info → lcode_cli-0.5.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -26,6 +26,7 @@ from lcode.mcp import McpManager
|
|
|
26
26
|
from lcode.ollama import Ollama, OllamaError
|
|
27
27
|
from lcode.permissions import Permissions
|
|
28
28
|
from lcode.render import MarkdownStreamer
|
|
29
|
+
from lcode.sandbox import Sandbox, SandboxError, project_root
|
|
29
30
|
from lcode.tools import (
|
|
30
31
|
SCHEMAS,
|
|
31
32
|
WEB_FETCH_SCHEMA,
|
|
@@ -140,6 +141,9 @@ class Settings:
|
|
|
140
141
|
search_backend: str = "auto"
|
|
141
142
|
searxng_url: str | None = None
|
|
142
143
|
checkpoints: bool = True # snapshot files before the model changes them, for /undo
|
|
144
|
+
sandbox: str = "off" # off, docker or podman: where the model's shell commands run
|
|
145
|
+
sandbox_image: str | None = None
|
|
146
|
+
sandbox_network: bool = False
|
|
143
147
|
|
|
144
148
|
|
|
145
149
|
class Agent:
|
|
@@ -153,6 +157,11 @@ class Agent:
|
|
|
153
157
|
self.session_id = self.new_session_id()
|
|
154
158
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
155
159
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
160
|
+
self.sandbox = (
|
|
161
|
+
Sandbox(settings.sandbox, settings.sandbox_image, settings.sandbox_network, self.console)
|
|
162
|
+
if settings.sandbox != "off"
|
|
163
|
+
else None
|
|
164
|
+
)
|
|
156
165
|
self._mcp_prompt = "" # the MCP part at the end of the system prompt
|
|
157
166
|
self.session_name = ""
|
|
158
167
|
self.session_title = ""
|
|
@@ -462,6 +471,15 @@ class Agent:
|
|
|
462
471
|
finally:
|
|
463
472
|
self.checkpoints.end_turn()
|
|
464
473
|
|
|
474
|
+
def sandbox_root(self) -> Path | None:
|
|
475
|
+
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
|
476
|
+
if not self.sandbox:
|
|
477
|
+
return None
|
|
478
|
+
try:
|
|
479
|
+
return project_root(self.cwd)
|
|
480
|
+
except SandboxError:
|
|
481
|
+
return self.cwd
|
|
482
|
+
|
|
465
483
|
def checkpoint(self) -> None:
|
|
466
484
|
"""Called before the model changes files: snapshot them once per request, for /undo."""
|
|
467
485
|
self.checkpoints.before_change(self.cwd)
|
lcode/cli.py
CHANGED
|
@@ -321,6 +321,15 @@ def cmd_doctor(args) -> None:
|
|
|
321
321
|
except ValueError as e:
|
|
322
322
|
line("MCP", str(e), False)
|
|
323
323
|
ok = False
|
|
324
|
+
if cfg["sandbox"] == "off":
|
|
325
|
+
line("Sandbox", "off (lcode config set sandbox docker)", True)
|
|
326
|
+
else:
|
|
327
|
+
from lcode.sandbox import engine_problem
|
|
328
|
+
|
|
329
|
+
problem = engine_problem(cfg["sandbox"])
|
|
330
|
+
network = "network on" if cfg["sandbox_network"] else "no network"
|
|
331
|
+
line("Sandbox", problem or f"{cfg['sandbox']} · {network}", problem is None)
|
|
332
|
+
ok &= problem is None
|
|
324
333
|
node = shutil.which("npx")
|
|
325
334
|
line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
|
|
326
335
|
sys.exit(0 if ok else 1)
|
|
@@ -485,8 +494,20 @@ def cmd_chat(args) -> None:
|
|
|
485
494
|
search_backend=cfg["search_backend"],
|
|
486
495
|
searxng_url=cfg["searxng_url"],
|
|
487
496
|
checkpoints=cfg["checkpoints"],
|
|
497
|
+
sandbox=(cfg["sandbox"] if cfg["sandbox"] != "off" else "docker") if args.sandbox else cfg["sandbox"],
|
|
498
|
+
sandbox_image=cfg["sandbox_image"],
|
|
499
|
+
sandbox_network=cfg["sandbox_network"],
|
|
488
500
|
)
|
|
489
501
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
502
|
+
if agent.sandbox:
|
|
503
|
+
# Never run commands unsandboxed when the user asked for a sandbox: stop here instead.
|
|
504
|
+
from lcode.sandbox import SandboxError
|
|
505
|
+
|
|
506
|
+
try:
|
|
507
|
+
with console.status("Starting the sandbox…"):
|
|
508
|
+
agent.sandbox.ensure(cwd)
|
|
509
|
+
except SandboxError as e:
|
|
510
|
+
fail(f"the sandbox can't start: {e}\nTurn it off with: lcode config set sandbox off")
|
|
490
511
|
if not args.no_mcp:
|
|
491
512
|
from lcode.mcp.commands import start_session
|
|
492
513
|
|
|
@@ -496,6 +517,8 @@ def cmd_chat(args) -> None:
|
|
|
496
517
|
finally:
|
|
497
518
|
if agent.mcp:
|
|
498
519
|
agent.mcp.close()
|
|
520
|
+
if agent.sandbox:
|
|
521
|
+
agent.sandbox.stop()
|
|
499
522
|
|
|
500
523
|
|
|
501
524
|
def build_parser() -> argparse.ArgumentParser:
|
|
@@ -522,6 +545,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
522
545
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
523
546
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
524
547
|
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
548
|
+
parser.add_argument("--sandbox", action="store_true", help="run shell commands in a container (Docker or Podman)")
|
|
525
549
|
parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
|
|
526
550
|
parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
|
|
527
551
|
return parser
|
lcode/config.py
CHANGED
|
@@ -25,6 +25,7 @@ PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
27
|
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
28
|
+
SANDBOX_ENGINES = ("off", "docker", "podman")
|
|
28
29
|
|
|
29
30
|
# key -> (default, type, help)
|
|
30
31
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -39,6 +40,9 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
39
40
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
40
41
|
"searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
|
|
41
42
|
"checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
|
|
43
|
+
"sandbox": ("off", str, "run the model's shell commands in a container: off | docker | podman"),
|
|
44
|
+
"sandbox_image": (None, str, "container image for the sandbox (default: lcode's, built on first use)"),
|
|
45
|
+
"sandbox_network": (False, bool, "let commands in the sandbox use the network"),
|
|
42
46
|
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
43
47
|
}
|
|
44
48
|
ENV_OVERRIDES = {
|
|
@@ -48,6 +52,7 @@ ENV_OVERRIDES = {
|
|
|
48
52
|
"LCODE_KEEP_ALIVE": "keep_alive",
|
|
49
53
|
"OLLAMA_HOST": "ollama_host",
|
|
50
54
|
"LCODE_WEB": "web",
|
|
55
|
+
"LCODE_SANDBOX": "sandbox",
|
|
51
56
|
}
|
|
52
57
|
|
|
53
58
|
|
|
@@ -99,6 +104,11 @@ def coerce(key: str, value: object) -> object:
|
|
|
99
104
|
if value not in WEB_MODES:
|
|
100
105
|
raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
|
|
101
106
|
return value
|
|
107
|
+
if key == "sandbox":
|
|
108
|
+
value = {"none": "off", "false": "off", "no": "off"}.get(str(value).lower(), str(value).lower())
|
|
109
|
+
if value not in SANDBOX_ENGINES:
|
|
110
|
+
raise ConfigError(f"sandbox must be one of {', '.join(SANDBOX_ENGINES)}")
|
|
111
|
+
return value
|
|
102
112
|
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
103
113
|
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
104
114
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
lcode/repl.py
CHANGED
|
@@ -39,6 +39,7 @@ COMMANDS = {
|
|
|
39
39
|
"/undo": "Undo the file changes of the last request (lcode saves a checkpoint before changing files)",
|
|
40
40
|
"/rewind": "Go back to before an earlier request: its files, and optionally the conversation",
|
|
41
41
|
"/checkpoints": "List the requests that changed files, and which files",
|
|
42
|
+
"/sandbox": "Shell-command sandbox: status, or /sandbox network on|off",
|
|
42
43
|
"/mcp": "MCP servers and their tools: /mcp, /mcp tools NAME, /mcp login NAME, /mcp restart NAME",
|
|
43
44
|
"/compact": "Summarize the conversation to free context (optional: what to focus on)",
|
|
44
45
|
"/context": "Show context usage and change the window size: pick from a list, or /context 128k",
|
|
@@ -143,6 +144,11 @@ def banner(agent: Agent) -> None:
|
|
|
143
144
|
f"[dim]mode[/] {agent.perms.mode} [dim](Shift+Tab to cycle)[/]\n"
|
|
144
145
|
f"[dim]web[/] {web_status(agent)}\n"
|
|
145
146
|
+ (f"[dim]mcp[/] {escape(', '.join(agent.mcp.servers))} [dim](/mcp)[/]\n" if agent.mcp else "")
|
|
147
|
+
+ (
|
|
148
|
+
f"[dim]sandbox[/] {escape(agent.sandbox.describe())} [dim](/sandbox)[/]\n"
|
|
149
|
+
if agent.sandbox
|
|
150
|
+
else ""
|
|
151
|
+
)
|
|
146
152
|
+ "\n"
|
|
147
153
|
"[dim]/help for commands · @file to attach · Esc+Enter for a newline[/]"
|
|
148
154
|
),
|
|
@@ -240,6 +246,31 @@ def context_command(agent: Agent, arg: str, hardware: Hardware) -> None:
|
|
|
240
246
|
apply_context(agent, size, hardware)
|
|
241
247
|
|
|
242
248
|
|
|
249
|
+
def sandbox_command(agent: Agent, arg: str) -> None:
|
|
250
|
+
c, sandbox = agent.console, agent.sandbox
|
|
251
|
+
if sandbox is None:
|
|
252
|
+
c.print(
|
|
253
|
+
"The sandbox is off: shell commands run directly on this machine. Start lcode with --sandbox, or "
|
|
254
|
+
"turn it on for every session with: lcode config set sandbox docker"
|
|
255
|
+
)
|
|
256
|
+
return
|
|
257
|
+
words = arg.split()
|
|
258
|
+
if len(words) == 2 and words[0] == "network" and words[1] in ("on", "off"):
|
|
259
|
+
sandbox.network = words[1] == "on"
|
|
260
|
+
sandbox.stop() # the next command starts a container with the new setting
|
|
261
|
+
c.print(f"Network access in the sandbox is {words[1]} from the next command.")
|
|
262
|
+
return
|
|
263
|
+
if arg:
|
|
264
|
+
c.print("[yellow]Use /sandbox or /sandbox network on|off.[/]")
|
|
265
|
+
return
|
|
266
|
+
where = f" · container {sandbox.container}, mounting {sandbox.root}" if sandbox.container else ""
|
|
267
|
+
c.print(f"Sandbox: {escape(sandbox.describe())}{escape(where)}")
|
|
268
|
+
c.print(
|
|
269
|
+
"[dim]Shell commands run in the container and can only see this project; file tools are limited to it "
|
|
270
|
+
"too. In auto-edit mode, commands run without asking.[/]"
|
|
271
|
+
)
|
|
272
|
+
|
|
273
|
+
|
|
243
274
|
def mcp_command(agent: Agent, arg: str) -> None:
|
|
244
275
|
from lcode.mcp.commands import status_table
|
|
245
276
|
from lcode.mcp.protocol import McpError
|
|
@@ -515,6 +546,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
|
515
546
|
print_checkpoints(agent)
|
|
516
547
|
elif cmd == "/mcp":
|
|
517
548
|
mcp_command(agent, arg)
|
|
549
|
+
elif cmd == "/sandbox":
|
|
550
|
+
sandbox_command(agent, arg)
|
|
518
551
|
elif cmd == "/compact":
|
|
519
552
|
agent.compact(arg)
|
|
520
553
|
elif cmd in ("/context", "/ctx"):
|
lcode/sandbox.py
ADDED
|
@@ -0,0 +1,204 @@
|
|
|
1
|
+
"""An optional sandbox: the model's shell commands run in a container that only sees the project.
|
|
2
|
+
|
|
3
|
+
One container per session. The project (the git repository around the working directory, or the
|
|
4
|
+
working directory itself) is mounted at the same path, so paths in command output stay valid and
|
|
5
|
+
lcode's file tools, which run on the host, see the same files. Commands run as the host user, with
|
|
6
|
+
no network unless it's allowed, no Linux capabilities, a process limit, and nothing else from the
|
|
7
|
+
host: not the home folder, not the Docker socket.
|
|
8
|
+
|
|
9
|
+
The default image is built locally on first use from the Dockerfile below. Any image with bash and
|
|
10
|
+
setsid (util-linux or busybox) can be used instead (`sandbox_image`).
|
|
11
|
+
"""
|
|
12
|
+
|
|
13
|
+
from __future__ import annotations
|
|
14
|
+
|
|
15
|
+
import hashlib
|
|
16
|
+
import os
|
|
17
|
+
import platform
|
|
18
|
+
import secrets
|
|
19
|
+
import shutil
|
|
20
|
+
import subprocess
|
|
21
|
+
from pathlib import Path
|
|
22
|
+
|
|
23
|
+
from rich.console import Console
|
|
24
|
+
|
|
25
|
+
DOCKERFILE = """\
|
|
26
|
+
FROM python:3.12-slim-bookworm
|
|
27
|
+
RUN apt-get update \\
|
|
28
|
+
&& apt-get install -y --no-install-recommends \\
|
|
29
|
+
git curl ca-certificates build-essential ripgrep jq unzip less procps nodejs npm \\
|
|
30
|
+
&& rm -rf /var/lib/apt/lists/*
|
|
31
|
+
"""
|
|
32
|
+
DEFAULT_IMAGE = f"lcode-sandbox:{hashlib.sha256(DOCKERFILE.encode()).hexdigest()[:12]}"
|
|
33
|
+
LABEL = "dev.lcode.sandbox"
|
|
34
|
+
HOME = "/tmp/lcode-home"
|
|
35
|
+
ENGINES = ("docker", "podman")
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class SandboxError(Exception):
|
|
39
|
+
"""The sandbox can't be used; the message says how to fix it."""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
def engine_problem(engine: str) -> str | None:
|
|
43
|
+
"""Why `engine` can't run containers here, or None if it can."""
|
|
44
|
+
if not shutil.which(engine):
|
|
45
|
+
if engine == "podman":
|
|
46
|
+
return "podman isn't installed (https://podman.io/docs/installation)"
|
|
47
|
+
if platform.system() == "Darwin":
|
|
48
|
+
return "docker isn't installed: install Docker Desktop, OrbStack or colima"
|
|
49
|
+
return "docker isn't installed (https://docs.docker.com/engine/install/)"
|
|
50
|
+
try:
|
|
51
|
+
r = subprocess.run(
|
|
52
|
+
[engine, "version", "--format", "{{.Server.Version}}"], capture_output=True, text=True, timeout=20
|
|
53
|
+
)
|
|
54
|
+
except (OSError, subprocess.SubprocessError) as e:
|
|
55
|
+
return f"{engine} doesn't respond: {e}"
|
|
56
|
+
if r.returncode != 0:
|
|
57
|
+
detail = (r.stderr.strip().splitlines() or [""])[-1]
|
|
58
|
+
if "permission denied" in detail.lower():
|
|
59
|
+
return (
|
|
60
|
+
f"{engine} needs permission: add yourself to the docker group "
|
|
61
|
+
"(sudo usermod -aG docker $USER, then log in again)"
|
|
62
|
+
)
|
|
63
|
+
if platform.system() == "Darwin":
|
|
64
|
+
return f"{engine} isn't running: start Docker Desktop, OrbStack or colima ({detail})"
|
|
65
|
+
return f"{engine} isn't running: start it (sudo systemctl start {engine}) ({detail})"
|
|
66
|
+
return None
|
|
67
|
+
|
|
68
|
+
|
|
69
|
+
def remove_orphans(engine: str) -> None:
|
|
70
|
+
"""Remove sandboxes left behind by lcode sessions that ended without cleaning up."""
|
|
71
|
+
try:
|
|
72
|
+
r = subprocess.run(
|
|
73
|
+
[engine, "ps", "-a", "--filter", f"label={LABEL}", "--format", '{{.ID}} {{.Label "dev.lcode.pid"}}'],
|
|
74
|
+
capture_output=True,
|
|
75
|
+
text=True,
|
|
76
|
+
timeout=20,
|
|
77
|
+
)
|
|
78
|
+
except (OSError, subprocess.SubprocessError):
|
|
79
|
+
return
|
|
80
|
+
stale = []
|
|
81
|
+
for line in r.stdout.split("\n"):
|
|
82
|
+
parts = line.split()
|
|
83
|
+
if len(parts) == 2 and parts[1].isdigit() and not _alive(int(parts[1])):
|
|
84
|
+
stale.append(parts[0])
|
|
85
|
+
if stale:
|
|
86
|
+
subprocess.run([engine, "rm", "-f", *stale], capture_output=True, timeout=60)
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _alive(pid: int) -> bool:
|
|
90
|
+
try:
|
|
91
|
+
os.kill(pid, 0)
|
|
92
|
+
except ProcessLookupError:
|
|
93
|
+
return False
|
|
94
|
+
except PermissionError:
|
|
95
|
+
return True
|
|
96
|
+
return True
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
class Sandbox:
|
|
100
|
+
def __init__(self, engine: str, image: str | None = None, network: bool = False, console: Console | None = None):
|
|
101
|
+
if engine not in ENGINES:
|
|
102
|
+
raise SandboxError(f"unknown sandbox {engine!r}; use docker or podman")
|
|
103
|
+
self.engine = engine
|
|
104
|
+
self.image = image or DEFAULT_IMAGE
|
|
105
|
+
self.network = network
|
|
106
|
+
self.console = console or Console()
|
|
107
|
+
self.container: str | None = None
|
|
108
|
+
self.root: Path | None = None
|
|
109
|
+
|
|
110
|
+
# -- lifecycle
|
|
111
|
+
def check(self) -> None:
|
|
112
|
+
problem = engine_problem(self.engine)
|
|
113
|
+
if problem:
|
|
114
|
+
raise SandboxError(problem)
|
|
115
|
+
|
|
116
|
+
def contains(self, path: Path) -> bool:
|
|
117
|
+
return self.root is not None and (path == self.root or self.root in path.parents)
|
|
118
|
+
|
|
119
|
+
def ensure(self, cwd: Path) -> None:
|
|
120
|
+
"""Have a container running whose mounted folder includes `cwd`."""
|
|
121
|
+
if self.container and self.contains(cwd):
|
|
122
|
+
return
|
|
123
|
+
self.stop()
|
|
124
|
+
self.check()
|
|
125
|
+
remove_orphans(self.engine)
|
|
126
|
+
if self.image == DEFAULT_IMAGE:
|
|
127
|
+
self._build_default_image()
|
|
128
|
+
root = project_root(cwd)
|
|
129
|
+
name = f"lcode-sandbox-{os.getpid()}-{secrets.token_hex(3)}"
|
|
130
|
+
command = [
|
|
131
|
+
self.engine, "run", "--detach", "--init", "--name", name,
|
|
132
|
+
"--label", LABEL, "--label", f"dev.lcode.pid={os.getpid()}",
|
|
133
|
+
"--cap-drop", "ALL", "--security-opt", "no-new-privileges", "--pids-limit", "1024",
|
|
134
|
+
"--volume", f"{root}:{root}", "--workdir", str(cwd),
|
|
135
|
+
"--env", f"HOME={HOME}", "--tmpfs", f"{HOME}:exec,mode=1777",
|
|
136
|
+
] # fmt: skip
|
|
137
|
+
if not self.network:
|
|
138
|
+
command += ["--network", "none"]
|
|
139
|
+
if self.engine == "podman":
|
|
140
|
+
command += ["--userns", "keep-id"] # rootless podman: files belong to the host user
|
|
141
|
+
else:
|
|
142
|
+
command += ["--user", f"{os.getuid()}:{os.getgid()}"]
|
|
143
|
+
command += [self.image, "sleep", "infinity"]
|
|
144
|
+
r = subprocess.run(command, capture_output=True, text=True, timeout=300)
|
|
145
|
+
if r.returncode != 0:
|
|
146
|
+
raise SandboxError(f"couldn't start the sandbox: {(r.stderr.strip().splitlines() or ['?'])[-1]}")
|
|
147
|
+
self.container, self.root = name, root
|
|
148
|
+
|
|
149
|
+
def _build_default_image(self) -> None:
|
|
150
|
+
exists = subprocess.run([self.engine, "image", "inspect", self.image], capture_output=True, timeout=30)
|
|
151
|
+
if exists.returncode == 0:
|
|
152
|
+
return
|
|
153
|
+
with self.console.status("Building the sandbox image (once; a minute or two)…"):
|
|
154
|
+
r = subprocess.run(
|
|
155
|
+
[self.engine, "build", "--tag", self.image, "-"],
|
|
156
|
+
input=DOCKERFILE,
|
|
157
|
+
capture_output=True,
|
|
158
|
+
text=True,
|
|
159
|
+
timeout=1800,
|
|
160
|
+
)
|
|
161
|
+
if r.returncode != 0:
|
|
162
|
+
tail = "\n".join(r.stderr.strip().splitlines()[-5:])
|
|
163
|
+
raise SandboxError(f"couldn't build the sandbox image:\n{tail}")
|
|
164
|
+
|
|
165
|
+
def exec_argv(self, cwd: Path, script: str) -> tuple[list[str], str]:
|
|
166
|
+
"""The command that runs `script` with bash inside the container, and a token for kill()."""
|
|
167
|
+
assert self.container is not None
|
|
168
|
+
token = secrets.token_hex(6)
|
|
169
|
+
# Its own session, so a timeout or Ctrl+C can stop everything the command started.
|
|
170
|
+
script = f"echo $$ > /tmp/.lcode-{token}.pgid\n{script}"
|
|
171
|
+
argv = [self.engine, "exec", "--interactive", "--workdir", str(cwd), self.container]
|
|
172
|
+
return [*argv, "setsid", "--wait", "bash", "-c", script], token
|
|
173
|
+
|
|
174
|
+
def kill(self, token: str) -> None:
|
|
175
|
+
"""Stop a command and everything it started (after a timeout or Ctrl+C); the container stays."""
|
|
176
|
+
if self.container:
|
|
177
|
+
pgid = f"$(cat /tmp/.lcode-{token}.pgid 2>/dev/null)"
|
|
178
|
+
subprocess.run(
|
|
179
|
+
# bash, not sh: dash's kill doesn't accept "--" before a negative (process group) id
|
|
180
|
+
[self.engine, "exec", self.container, "bash", "-c", f'[ -n "{pgid}" ] && kill -KILL -- -{pgid}'],
|
|
181
|
+
capture_output=True,
|
|
182
|
+
timeout=30,
|
|
183
|
+
)
|
|
184
|
+
|
|
185
|
+
def stop(self) -> None:
|
|
186
|
+
if self.container:
|
|
187
|
+
subprocess.run([self.engine, "rm", "--force", self.container], capture_output=True, timeout=60)
|
|
188
|
+
self.container, self.root = None, None
|
|
189
|
+
|
|
190
|
+
def describe(self) -> str:
|
|
191
|
+
image = "lcode's image" if self.image == DEFAULT_IMAGE else self.image
|
|
192
|
+
network = "network on" if self.network else "no network"
|
|
193
|
+
return f"{self.engine} · {image} · {network}"
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
def project_root(cwd: Path) -> Path:
|
|
197
|
+
"""The folder the sandbox mounts: the git repository around `cwd`, or `cwd` itself."""
|
|
198
|
+
from lcode.checkpoints import work_tree_for
|
|
199
|
+
|
|
200
|
+
root = work_tree_for(cwd)
|
|
201
|
+
home = Path.home().resolve()
|
|
202
|
+
if root == home or root in home.parents or root == Path("/"):
|
|
203
|
+
raise SandboxError(f"won't mount {root} into the sandbox; start lcode in a project folder")
|
|
204
|
+
return root
|
lcode/tools.py
CHANGED
|
@@ -9,11 +9,10 @@ import json
|
|
|
9
9
|
import os
|
|
10
10
|
import queue
|
|
11
11
|
import re
|
|
12
|
-
import
|
|
12
|
+
import secrets
|
|
13
13
|
import shutil
|
|
14
14
|
import signal
|
|
15
15
|
import subprocess
|
|
16
|
-
import tempfile
|
|
17
16
|
import threading
|
|
18
17
|
import time
|
|
19
18
|
from pathlib import Path
|
|
@@ -25,10 +24,15 @@ from rich.text import Text
|
|
|
25
24
|
|
|
26
25
|
from lcode import web
|
|
27
26
|
from lcode.permissions import bash_key, is_read_only
|
|
27
|
+
from lcode.sandbox import SandboxError
|
|
28
28
|
|
|
29
29
|
if TYPE_CHECKING:
|
|
30
30
|
from lcode.agent import Agent
|
|
31
31
|
|
|
32
|
+
NO_NETWORK = re.compile(
|
|
33
|
+
r"Network is unreachable|Temporary failure in name resolution|Could not resolve host|getaddrinfo|ENOTFOUND|"
|
|
34
|
+
r"EAI_AGAIN|Name or service not known|network is unreachable|No route to host"
|
|
35
|
+
)
|
|
32
36
|
MAX_TOOL_OUTPUT = 30_000 # characters returned to the model per tool call
|
|
33
37
|
IGNORE_DIRS = {
|
|
34
38
|
".git", "node_modules", "__pycache__", ".venv", "venv", "env", ".mypy_cache", ".pytest_cache",
|
|
@@ -277,6 +281,14 @@ class Toolbox:
|
|
|
277
281
|
p = Path(os.path.expanduser(path or "."))
|
|
278
282
|
return (p if p.is_absolute() else self.agent.cwd / p).resolve()
|
|
279
283
|
|
|
284
|
+
def path(self, path: str | None) -> Path:
|
|
285
|
+
"""Resolve a path from the model; with the sandbox on, it must be inside the project."""
|
|
286
|
+
p = self.resolve(path)
|
|
287
|
+
root = self.agent.sandbox_root()
|
|
288
|
+
if root is not None and p != root and root not in p.parents:
|
|
289
|
+
raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
|
|
290
|
+
return p
|
|
291
|
+
|
|
280
292
|
def rel(self, p: Path) -> str:
|
|
281
293
|
try:
|
|
282
294
|
return str(p.relative_to(self.agent.cwd)) or "."
|
|
@@ -344,7 +356,7 @@ class Toolbox:
|
|
|
344
356
|
|
|
345
357
|
# -- read-only
|
|
346
358
|
def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
|
|
347
|
-
p = self.
|
|
359
|
+
p = self.path(path)
|
|
348
360
|
if not p.exists():
|
|
349
361
|
raise ToolError(f"{path} does not exist")
|
|
350
362
|
if p.is_dir():
|
|
@@ -364,13 +376,13 @@ class Toolbox:
|
|
|
364
376
|
return truncate(out, 120_000)
|
|
365
377
|
|
|
366
378
|
def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
|
|
367
|
-
p = self.
|
|
379
|
+
p = self.path(path)
|
|
368
380
|
if not p.is_dir():
|
|
369
381
|
raise ToolError(f"{path} is not a directory")
|
|
370
382
|
return tree(p, depth=max(1, min(int(depth or 2), 6)))
|
|
371
383
|
|
|
372
384
|
def t_glob(self, pattern: str, path: str = ".") -> str:
|
|
373
|
-
base = self.
|
|
385
|
+
base = self.path(path)
|
|
374
386
|
pattern = pattern.removeprefix("./")
|
|
375
387
|
patterns = [pattern] + ([pattern[3:]] if pattern.startswith("**/") else [])
|
|
376
388
|
hits = [
|
|
@@ -385,7 +397,7 @@ class Toolbox:
|
|
|
385
397
|
def t_grep(
|
|
386
398
|
self, pattern: str, path: str = ".", glob: str | None = None, ignore_case: bool = False, context: int = 0
|
|
387
399
|
) -> str:
|
|
388
|
-
p = self.
|
|
400
|
+
p = self.path(path)
|
|
389
401
|
if shutil.which("rg"):
|
|
390
402
|
cmd = ["rg", "--line-number", "--no-heading", "--with-filename", "--hidden", "--color", "never"]
|
|
391
403
|
cmd += ["--max-columns", "400"]
|
|
@@ -450,7 +462,7 @@ class Toolbox:
|
|
|
450
462
|
self.read_mtimes[str(p)] = p.stat().st_mtime
|
|
451
463
|
|
|
452
464
|
def t_write_file(self, path: str, content: str) -> str:
|
|
453
|
-
p = self.
|
|
465
|
+
p = self.path(path)
|
|
454
466
|
exists = p.exists()
|
|
455
467
|
if exists:
|
|
456
468
|
if p.is_dir():
|
|
@@ -472,7 +484,7 @@ class Toolbox:
|
|
|
472
484
|
return f"{'Overwrote' if exists else 'Created'} {self.rel(p)} ({n} lines)."
|
|
473
485
|
|
|
474
486
|
def t_edit_file(self, path: str, old_string: str, new_string: str, replace_all: bool = False) -> str:
|
|
475
|
-
p = self.
|
|
487
|
+
p = self.path(path)
|
|
476
488
|
if not p.exists():
|
|
477
489
|
raise ToolError(f"{path} does not exist (use write_file to create it)")
|
|
478
490
|
self._check_fresh(p)
|
|
@@ -515,20 +527,30 @@ class Toolbox:
|
|
|
515
527
|
|
|
516
528
|
# -- shell
|
|
517
529
|
def t_bash(self, command: str, timeout: int = 180) -> str:
|
|
518
|
-
|
|
530
|
+
sandbox = self.agent.sandbox
|
|
531
|
+
if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit"):
|
|
519
532
|
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
520
|
-
|
|
521
|
-
|
|
522
|
-
)
|
|
533
|
+
where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
|
|
534
|
+
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body)
|
|
523
535
|
if not ok:
|
|
524
536
|
return feedback
|
|
537
|
+
if not is_read_only(command):
|
|
525
538
|
self.agent.checkpoint()
|
|
526
539
|
self.console.print(Text(f" $ {command}", style="bold cyan"))
|
|
527
|
-
|
|
528
|
-
|
|
529
|
-
|
|
540
|
+
marker = f"__lcode_cwd_{secrets.token_hex(8)}__"
|
|
541
|
+
script = f"{command}\n__lcode_ec=$?\nprintf '\\n{marker}%s\\n' \"$(pwd -P)\"\nexit $__lcode_ec\n"
|
|
542
|
+
token = ""
|
|
543
|
+
if sandbox:
|
|
544
|
+
try:
|
|
545
|
+
with self.console.status("Starting the sandbox…"):
|
|
546
|
+
sandbox.ensure(self.agent.cwd)
|
|
547
|
+
except SandboxError as e:
|
|
548
|
+
return f"Error: the sandbox can't start, so the command didn't run: {e}"
|
|
549
|
+
argv, token = sandbox.exec_argv(self.agent.cwd, script)
|
|
550
|
+
else:
|
|
551
|
+
argv = ["bash", "-c", script]
|
|
530
552
|
proc = subprocess.Popen(
|
|
531
|
-
|
|
553
|
+
argv,
|
|
532
554
|
cwd=self.agent.cwd,
|
|
533
555
|
stdout=subprocess.PIPE,
|
|
534
556
|
stderr=subprocess.STDOUT,
|
|
@@ -546,9 +568,14 @@ class Toolbox:
|
|
|
546
568
|
lines.put(line)
|
|
547
569
|
lines.put(None)
|
|
548
570
|
|
|
571
|
+
def stop() -> None:
|
|
572
|
+
if sandbox and token:
|
|
573
|
+
sandbox.kill(token) # the command runs in the container, not under our process
|
|
574
|
+
os.killpg(proc.pid, signal.SIGKILL)
|
|
575
|
+
|
|
549
576
|
threading.Thread(target=pump, daemon=True).start()
|
|
550
577
|
out: list[str] = []
|
|
551
|
-
shown, status = 0, ""
|
|
578
|
+
shown, status, recorded = 0, "", ""
|
|
552
579
|
deadline = time.time() + int(timeout or 180)
|
|
553
580
|
try:
|
|
554
581
|
while True:
|
|
@@ -556,30 +583,37 @@ class Toolbox:
|
|
|
556
583
|
line = lines.get(timeout=0.2)
|
|
557
584
|
except queue.Empty:
|
|
558
585
|
if time.time() > deadline:
|
|
559
|
-
|
|
586
|
+
stop()
|
|
560
587
|
status = f"\n[Command timed out after {timeout}s and was killed]"
|
|
561
588
|
break
|
|
562
589
|
continue
|
|
563
590
|
if line is None:
|
|
564
591
|
break
|
|
592
|
+
if line.startswith(marker):
|
|
593
|
+
recorded = line[len(marker) :].strip()
|
|
594
|
+
continue
|
|
565
595
|
out.append(line)
|
|
566
596
|
if shown <= 40:
|
|
567
597
|
msg = " ... (more output hidden)" if shown == 40 else " " + line.rstrip("\n")[:300]
|
|
568
598
|
self.console.print(Text(msg, style="dim"))
|
|
569
599
|
shown += 1
|
|
570
600
|
except KeyboardInterrupt:
|
|
571
|
-
|
|
601
|
+
stop()
|
|
572
602
|
raise
|
|
573
603
|
code = proc.wait()
|
|
574
|
-
|
|
575
|
-
|
|
576
|
-
|
|
604
|
+
if out and out[-1] == "\n":
|
|
605
|
+
out.pop() # the blank line printed before the marker
|
|
606
|
+
if recorded and Path(recorded).is_dir() and Path(recorded).resolve() != self.agent.cwd:
|
|
607
|
+
if sandbox and not sandbox.contains(Path(recorded).resolve()):
|
|
608
|
+
status += "\n[That directory is outside the project; the working directory stays the same]"
|
|
609
|
+
else:
|
|
577
610
|
self.agent.cwd = Path(recorded).resolve()
|
|
578
611
|
status += f"\n[Working directory is now {self.agent.cwd}]"
|
|
579
|
-
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
612
|
+
if sandbox and code != 0 and not sandbox.network and NO_NETWORK.search("".join(out[-40:])):
|
|
613
|
+
status += (
|
|
614
|
+
"\n[The sandbox has no network access. If this needs the network, ask the user to allow it "
|
|
615
|
+
"with /sandbox network on]"
|
|
616
|
+
)
|
|
583
617
|
self.console.print(Text(f" exit code {code}", style="green" if code == 0 else "red"))
|
|
584
618
|
return truncate("".join(out)) + status + f"\n[exit code: {code}]"
|
|
585
619
|
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.5.0
|
|
4
4
|
Summary: A local-first terminal coding agent powered by open-weight models on your own GPU or Mac, via Ollama.
|
|
5
5
|
Project-URL: Homepage, https://nasser1941.github.io/lcode/
|
|
6
6
|
Project-URL: Documentation, https://nasser1941.github.io/lcode/
|
|
@@ -22,6 +22,7 @@ Classifier: Programming Language :: Python :: 3.10
|
|
|
22
22
|
Classifier: Programming Language :: Python :: 3.11
|
|
23
23
|
Classifier: Programming Language :: Python :: 3.12
|
|
24
24
|
Classifier: Programming Language :: Python :: 3.13
|
|
25
|
+
Classifier: Programming Language :: Python :: 3.14
|
|
25
26
|
Classifier: Topic :: Scientific/Engineering :: Artificial Intelligence
|
|
26
27
|
Classifier: Topic :: Software Development
|
|
27
28
|
Requires-Python: >=3.10
|
|
@@ -77,6 +78,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
77
78
|
real coding tasks on your machine.
|
|
78
79
|
- **Safe by default.** Every edit is shown as a diff and every command that isn't read-only needs
|
|
79
80
|
your approval. `auto-edit` and `yolo` modes when you want speed.
|
|
81
|
+
- **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
|
|
82
|
+
Podman) that only sees your project, without network access unless you allow it.
|
|
80
83
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
81
84
|
request's edits, new files and shell-command changes, without touching your git history.
|
|
82
85
|
- **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
|
|
@@ -107,6 +110,13 @@ uv tool install lcode-cli # or: pipx install lcode-cli
|
|
|
107
110
|
lcode setup
|
|
108
111
|
```
|
|
109
112
|
|
|
113
|
+
On a Mac with [Homebrew](https://brew.sh):
|
|
114
|
+
|
|
115
|
+
```bash
|
|
116
|
+
brew install nasser1941/tap/lcode
|
|
117
|
+
lcode setup
|
|
118
|
+
```
|
|
119
|
+
|
|
110
120
|
See the [installation guide](https://nasser1941.github.io/lcode/installation/) for details.
|
|
111
121
|
|
|
112
122
|
## Quickstart
|
|
@@ -176,7 +186,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
176
186
|
|
|
177
187
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
178
188
|
|
|
179
|
-
- [Homebrew formula](https://github.com/nasser1941/lcode/issues/8) and an [optional sandbox for shell commands](https://github.com/nasser1941/lcode/issues/9)
|
|
180
189
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
181
190
|
|
|
182
191
|
## Contributing
|
|
@@ -1,20 +1,21 @@
|
|
|
1
|
-
lcode/__init__.py,sha256
|
|
1
|
+
lcode/__init__.py,sha256=-nk7j2Efg9_MFgFlQo5YWln10lcT6SR31CYBoENbTLo,117
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=evwGEJg8zWvA2SnP4yI_vkWba3ILICK9OzVb0cbk6bw,28478
|
|
4
4
|
lcode/bench.py,sha256=FCn94Gs7L8bpq4hzf6ILoQG8dKrbbd5WC4BRwrTp4LM,37950
|
|
5
5
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
6
6
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
7
|
-
lcode/cli.py,sha256=
|
|
8
|
-
lcode/config.py,sha256=
|
|
7
|
+
lcode/cli.py,sha256=iGP5qywc7tUFuLNpzcrlYTicFX6dAaQpeXCfElqxPdQ,27041
|
|
8
|
+
lcode/config.py,sha256=1_dhAwLRZD2rEhD47rBl3W4kvW0lQ6-45TQdq1KHz58,6956
|
|
9
9
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
10
10
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
11
11
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
12
12
|
lcode/ollama.py,sha256=-EB_8DKh87larG7iV0neyndPRom1Hxh4y8AAfI1Ebq8,6690
|
|
13
13
|
lcode/permissions.py,sha256=OcWUDLrSoohUWHDUQ3UuBE3zI2JnBnOPizLE3bIgqyU,3982
|
|
14
14
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
15
|
-
lcode/repl.py,sha256=
|
|
15
|
+
lcode/repl.py,sha256=2p6UG1xCmn85YEYcWUtgfoLJc3rHLaGLb_VV-RvzYvw,30332
|
|
16
|
+
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
16
17
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
17
|
-
lcode/tools.py,sha256=
|
|
18
|
+
lcode/tools.py,sha256=Y23bVt7Bow72v1QJUpvSahoEep77XW8e44Mg-s9igds,29146
|
|
18
19
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
19
20
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
20
21
|
lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
|
|
@@ -27,8 +28,8 @@ lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
|
|
|
27
28
|
lcode/mcp/manager.py,sha256=UPpY97jwW_KIi2G4hR860Iq4zgfqNFHjsyeu48aEa44,14784
|
|
28
29
|
lcode/mcp/protocol.py,sha256=8uhyzzDQmaXr6pdt9bWD5ccvWZ93rvxd4v3Z5J84vKw,4938
|
|
29
30
|
lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
30
|
-
lcode_cli-0.
|
|
31
|
-
lcode_cli-0.
|
|
32
|
-
lcode_cli-0.
|
|
33
|
-
lcode_cli-0.
|
|
34
|
-
lcode_cli-0.
|
|
31
|
+
lcode_cli-0.5.0.dist-info/METADATA,sha256=AKzBZkJaIZLAFgClC2t3OhO36jiVaAAXB7d1EXFEV8Q,9965
|
|
32
|
+
lcode_cli-0.5.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
33
|
+
lcode_cli-0.5.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
|
|
34
|
+
lcode_cli-0.5.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
35
|
+
lcode_cli-0.5.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|