lcode-cli 0.14.0__py3-none-any.whl → 0.15.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 +11 -0
- lcode/cli.py +17 -1
- lcode/config.py +6 -1
- lcode/extensions.py +18 -3
- lcode/hooks.py +241 -0
- lcode/permissions.py +19 -1
- lcode/repl.py +17 -0
- lcode/subagents.py +15 -4
- lcode/tools.py +46 -6
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.15.0.dist-info}/METADATA +5 -2
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.15.0.dist-info}/RECORD +15 -14
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.15.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.15.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.14.0.dist-info → lcode_cli-0.15.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -216,6 +216,9 @@ class Agent:
|
|
|
216
216
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
217
217
|
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
218
218
|
self.lsp = None # an lcode.lsp.Manager, set by the CLI when language servers are installed
|
|
219
|
+
from lcode.hooks import HookSet
|
|
220
|
+
|
|
221
|
+
self.hooks = HookSet() # set by the CLI from the settings (lcode.hooks)
|
|
219
222
|
self._repo_map: tuple[Path, repomap.RepoMap, str] | None = None
|
|
220
223
|
self._code_index: tuple[Path, codesearch.Index | None] | None = None
|
|
221
224
|
self.sandbox = (
|
|
@@ -722,6 +725,14 @@ class Agent:
|
|
|
722
725
|
self._run_turn(user_text)
|
|
723
726
|
finally:
|
|
724
727
|
self.checkpoints.end_turn()
|
|
728
|
+
if self.hooks.hooks and self.allowed_tools is None:
|
|
729
|
+
payload = {"request": user_text[:2000], "cwd": str(self.cwd), "session": self.session_id}
|
|
730
|
+
for outcome in self.hooks.run("after_request", payload, self.cwd):
|
|
731
|
+
style = "dim" if outcome.code == 0 else "yellow"
|
|
732
|
+
if outcome.output or outcome.code:
|
|
733
|
+
self.console.print(
|
|
734
|
+
Text(f" ⎿ after_request hook ({outcome.code}): {outcome.output[:500]}", style=style)
|
|
735
|
+
)
|
|
725
736
|
|
|
726
737
|
def sandbox_root(self) -> Path | None:
|
|
727
738
|
"""The folder the model is limited to while the sandbox is on (None when it's off)."""
|
lcode/cli.py
CHANGED
|
@@ -368,7 +368,16 @@ def cmd_doctor(args) -> None:
|
|
|
368
368
|
line("", f"{n} × {slots[1]} context may not fit in memory here; {slots[0]} would", None)
|
|
369
369
|
for problem in problems:
|
|
370
370
|
line("", problem, None)
|
|
371
|
-
from lcode import codesearch, extensions, lsp
|
|
371
|
+
from lcode import codesearch, extensions, hooks, lsp
|
|
372
|
+
|
|
373
|
+
hook_set, rules = hooks.load(Path.cwd(), extensions.status(Path.cwd()) == "approved")
|
|
374
|
+
if hook_set.hooks or rules or hook_set.problems:
|
|
375
|
+
events = sorted({h.event for h in hook_set.hooks})
|
|
376
|
+
summary = f"{len(hook_set.hooks)} hook(s) ({', '.join(events)})" if hook_set.hooks else "no hooks"
|
|
377
|
+
summary += f", {len(rules.allow)} allow and {len(rules.deny)} deny rule(s)"
|
|
378
|
+
line("Hooks", summary, not hook_set.problems or None)
|
|
379
|
+
for problem in hook_set.problems:
|
|
380
|
+
line("", problem, None)
|
|
372
381
|
from lcode.checkpoints import work_tree_for
|
|
373
382
|
|
|
374
383
|
if cfg["embed_model"] == "off":
|
|
@@ -602,6 +611,13 @@ def cmd_chat(args) -> None:
|
|
|
602
611
|
)
|
|
603
612
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
604
613
|
agent.interactive = not args.prompt
|
|
614
|
+
from lcode import hooks
|
|
615
|
+
|
|
616
|
+
agent.hooks, agent.perms.rules = hooks.load(cwd, trust_project)
|
|
617
|
+
for problem in agent.hooks.problems:
|
|
618
|
+
console.print(f"[yellow]Settings: {problem}[/]")
|
|
619
|
+
if agent.hooks.for_event("notification"):
|
|
620
|
+
agent.perms.on_prompt = lambda title: agent.hooks.notify(f"lcode needs you: {title}", agent.cwd)
|
|
605
621
|
if cfg["lsp"] == "auto":
|
|
606
622
|
from lcode import lsp
|
|
607
623
|
from lcode.checkpoints import work_tree_for
|
lcode/config.py
CHANGED
|
@@ -187,5 +187,10 @@ def save(updates: dict, path: Path | None = None, remove: tuple[str, ...] = ())
|
|
|
187
187
|
if data.get(k) is not None:
|
|
188
188
|
v = data[k]
|
|
189
189
|
lines.append(f"{k} = {str(v).lower() if isinstance(v, bool) else json.dumps(v)}")
|
|
190
|
+
tables = "" # [[hooks]], [permissions] and anything else after the settings stay as they were
|
|
191
|
+
if path.is_file():
|
|
192
|
+
text = path.read_text()
|
|
193
|
+
m = re.search(r"^\s*\[", text, re.M)
|
|
194
|
+
tables = text[m.start() :] if m else ""
|
|
190
195
|
path.parent.mkdir(parents=True, exist_ok=True)
|
|
191
|
-
path.write_text("\n".join(lines) + "\n")
|
|
196
|
+
path.write_text("\n".join(lines) + "\n" + ("\n" + tables.rstrip() + "\n" if tables.strip() else ""))
|
lcode/extensions.py
CHANGED
|
@@ -286,7 +286,7 @@ def approvals_path() -> Path:
|
|
|
286
286
|
def project_files(cwd: Path) -> tuple[Path, list[Path]]:
|
|
287
287
|
"""The repository's root and the files of its commands, skills and agents."""
|
|
288
288
|
root = work_tree_for(cwd)
|
|
289
|
-
files: list[Path] = []
|
|
289
|
+
files: list[Path] = [root / ".lcode" / "settings.toml"] if (root / ".lcode" / "settings.toml").is_file() else []
|
|
290
290
|
for folder in PROJECT_FOLDERS:
|
|
291
291
|
base = root / folder
|
|
292
292
|
if not base.is_dir():
|
|
@@ -359,6 +359,21 @@ def describe(cwd: Path) -> list[str]:
|
|
|
359
359
|
lines.append(f"skills: {', '.join(project_skills)}")
|
|
360
360
|
if agents:
|
|
361
361
|
lines.append(f"agents: {', '.join(agents)}")
|
|
362
|
+
settings = root / ".lcode" / "settings.toml"
|
|
363
|
+
if settings.is_file():
|
|
364
|
+
from lcode.hooks import HookError, HookSet, Rules, read_toml
|
|
365
|
+
|
|
366
|
+
try:
|
|
367
|
+
data = read_toml(settings)
|
|
368
|
+
hook_set, rules = HookSet(), Rules()
|
|
369
|
+
hook_set.add(data, "")
|
|
370
|
+
rules.add(data, "")
|
|
371
|
+
commands = sorted({h.command.split()[0] for h in hook_set.hooks if h.command.split()})
|
|
372
|
+
parts = [f"{len(hook_set.hooks)} hook(s) running {', '.join(commands)}" if hook_set.hooks else ""]
|
|
373
|
+
parts.append(f"{len(rules.allow)} allow and {len(rules.deny)} deny rule(s)" if rules else "")
|
|
374
|
+
lines.append("settings: " + "; ".join(p for p in parts if p) if any(parts) else "settings: (empty)")
|
|
375
|
+
except HookError as e:
|
|
376
|
+
lines.append(f"settings: {e}")
|
|
362
377
|
return lines
|
|
363
378
|
|
|
364
379
|
|
|
@@ -378,8 +393,8 @@ def trust_project(cwd: Path, console: Console, interactive: bool) -> bool:
|
|
|
378
393
|
for line in describe(cwd) or ["(files in " + ", ".join(PROJECT_FOLDERS) + ")"]:
|
|
379
394
|
console.print(f" {escape(line)}")
|
|
380
395
|
console.print(
|
|
381
|
-
"[dim]They can steer the model and include scripts it may run
|
|
382
|
-
"
|
|
396
|
+
"[dim]They can steer the model and include scripts it may run, and its settings can run hooks "
|
|
397
|
+
"(shell commands) on lcode's events.[/]"
|
|
383
398
|
)
|
|
384
399
|
try:
|
|
385
400
|
answer = input(" Use them? [y/N] ").strip().lower()
|
lcode/hooks.py
ADDED
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
"""Hooks and permission rules: what should always happen, whatever the model does.
|
|
2
|
+
|
|
3
|
+
Hooks are shell commands that run on lcode's events, set in `~/.config/lcode/config.toml` or, per
|
|
4
|
+
repository, in `.lcode/settings.toml` (used once you approve it, like the repository's commands):
|
|
5
|
+
|
|
6
|
+
[[hooks]]
|
|
7
|
+
event = "after_tool" # before_tool | after_tool | after_request | session_start | notification
|
|
8
|
+
tools = ["edit_file", "write_file"]
|
|
9
|
+
paths = ["*.py"]
|
|
10
|
+
command = "ruff format {path} && ruff check --fix {path}"
|
|
11
|
+
|
|
12
|
+
The event comes as JSON on stdin (and `{path}`, `{tool}` in the command are replaced, quoted). A
|
|
13
|
+
`before_tool` hook that exits with code 2 blocks the call, and what it printed tells the model why.
|
|
14
|
+
An `after_tool` hook's output reaches the model when it fails, or always with `feedback = true`.
|
|
15
|
+
|
|
16
|
+
Permission rules are checked before lcode asks, and deny always wins, even in yolo mode:
|
|
17
|
+
|
|
18
|
+
[permissions]
|
|
19
|
+
allow = ["bash:pytest *", "bash:npm test", "edit:src/**"]
|
|
20
|
+
deny = ["bash:git push --force*", "edit:.env"]
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
import fnmatch
|
|
26
|
+
import json
|
|
27
|
+
import os
|
|
28
|
+
import re
|
|
29
|
+
import shlex
|
|
30
|
+
import subprocess
|
|
31
|
+
import sys
|
|
32
|
+
import threading
|
|
33
|
+
from dataclasses import dataclass, field
|
|
34
|
+
from pathlib import Path
|
|
35
|
+
|
|
36
|
+
if sys.version_info >= (3, 11):
|
|
37
|
+
import tomllib
|
|
38
|
+
else: # pragma: no cover
|
|
39
|
+
import tomli as tomllib
|
|
40
|
+
|
|
41
|
+
from lcode import config
|
|
42
|
+
from lcode.checkpoints import work_tree_for
|
|
43
|
+
|
|
44
|
+
EVENTS = ("before_tool", "after_tool", "after_request", "session_start", "notification")
|
|
45
|
+
BLOCK = 2 # exit code of a before_tool hook that blocks the call
|
|
46
|
+
MAX_OUTPUT = 4000
|
|
47
|
+
FILE_TOOLS = {"read_file", "write_file", "edit_file", "view_image", "list_dir"}
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class HookError(ValueError):
|
|
51
|
+
pass
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
def project_settings(cwd: Path) -> Path:
|
|
55
|
+
return work_tree_for(cwd) / ".lcode" / "settings.toml"
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def read_toml(path: Path) -> dict:
|
|
59
|
+
try:
|
|
60
|
+
return tomllib.loads(path.read_text())
|
|
61
|
+
except OSError:
|
|
62
|
+
return {}
|
|
63
|
+
except tomllib.TOMLDecodeError as e:
|
|
64
|
+
raise HookError(f"{path} isn't valid TOML: {e}") from e
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
# ----------------------------------------------------------------------------- permission rules
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
def strip_cd(command: str) -> str:
|
|
71
|
+
return re.sub(r"^\s*(cd\s+\S+\s*&&\s*)+", "", command).strip()
|
|
72
|
+
|
|
73
|
+
|
|
74
|
+
def matches_path(pattern: str, path: str) -> bool:
|
|
75
|
+
"""gitignore-like: a pattern without a slash matches the file name anywhere."""
|
|
76
|
+
if "/" not in pattern.rstrip("/"):
|
|
77
|
+
return fnmatch.fnmatch(Path(path).name, pattern) or fnmatch.fnmatch(path, pattern)
|
|
78
|
+
pattern = pattern.removeprefix("./").removeprefix("/")
|
|
79
|
+
return fnmatch.fnmatch(path, pattern) or (pattern.startswith("**/") and fnmatch.fnmatch(path, pattern[3:]))
|
|
80
|
+
|
|
81
|
+
|
|
82
|
+
@dataclass
|
|
83
|
+
class Rules:
|
|
84
|
+
allow: list[tuple[str, str]] = field(default_factory=list) # (rule, where it's from)
|
|
85
|
+
deny: list[tuple[str, str]] = field(default_factory=list)
|
|
86
|
+
|
|
87
|
+
def add(self, data: dict, source: str) -> None:
|
|
88
|
+
section = data.get("permissions") or {}
|
|
89
|
+
for kind in ("allow", "deny"):
|
|
90
|
+
values = section.get(kind) or []
|
|
91
|
+
if not isinstance(values, list) or not all(isinstance(v, str) and ":" in v for v in values):
|
|
92
|
+
raise HookError(f'{source}: permissions.{kind} must be a list of rules like "bash:pytest *"')
|
|
93
|
+
getattr(self, kind).extend((v, source) for v in values)
|
|
94
|
+
|
|
95
|
+
def _matches(self, rule: str, kind: str, target: str) -> bool:
|
|
96
|
+
rule_kind, _, pattern = rule.partition(":")
|
|
97
|
+
if rule_kind != kind or not target:
|
|
98
|
+
return False
|
|
99
|
+
if kind == "bash":
|
|
100
|
+
return fnmatch.fnmatch(strip_cd(target), pattern)
|
|
101
|
+
if kind == "edit":
|
|
102
|
+
return matches_path(pattern, target)
|
|
103
|
+
return fnmatch.fnmatch(target, pattern)
|
|
104
|
+
|
|
105
|
+
def check(self, kind: str, target: str) -> tuple[str | None, str]:
|
|
106
|
+
"""("deny" or "allow", the rule and where it's from), or (None, "")."""
|
|
107
|
+
if kind == "bash": # a denied command stays denied inside a chain: git add . && git push --force
|
|
108
|
+
parts = [p for p in re.split(r"\s*(?:&&|\|\||;|\|)\s*", strip_cd(target)) if p]
|
|
109
|
+
for rule, source in self.deny:
|
|
110
|
+
if any(self._matches(rule, kind, part) for part in [target, *parts]):
|
|
111
|
+
return "deny", f"{rule} ({source})"
|
|
112
|
+
for rule, source in self.allow:
|
|
113
|
+
if self._matches(rule, kind, target):
|
|
114
|
+
return "allow", f"{rule} ({source})"
|
|
115
|
+
if parts and all(any(self._matches(r, kind, part) for r, _ in self.allow) for part in parts):
|
|
116
|
+
return "allow", "every part of the command is allowed"
|
|
117
|
+
return None, ""
|
|
118
|
+
for rule, source in self.deny:
|
|
119
|
+
if self._matches(rule, kind, target):
|
|
120
|
+
return "deny", f"{rule} ({source})"
|
|
121
|
+
for rule, source in self.allow:
|
|
122
|
+
if self._matches(rule, kind, target):
|
|
123
|
+
return "allow", f"{rule} ({source})"
|
|
124
|
+
return None, ""
|
|
125
|
+
|
|
126
|
+
def __bool__(self) -> bool:
|
|
127
|
+
return bool(self.allow or self.deny)
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
# ----------------------------------------------------------------------------- hooks
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
@dataclass
|
|
134
|
+
class Hook:
|
|
135
|
+
event: str
|
|
136
|
+
command: str
|
|
137
|
+
source: str
|
|
138
|
+
tools: list[str] = field(default_factory=list)
|
|
139
|
+
paths: list[str] = field(default_factory=list)
|
|
140
|
+
timeout: float = 60.0
|
|
141
|
+
feedback: bool = False
|
|
142
|
+
|
|
143
|
+
def applies(self, tool: str = "", path: str = "") -> bool:
|
|
144
|
+
if self.tools and not any(fnmatch.fnmatch(tool, t) for t in self.tools):
|
|
145
|
+
return False
|
|
146
|
+
return not self.paths or bool(path and any(matches_path(p, path) for p in self.paths))
|
|
147
|
+
|
|
148
|
+
|
|
149
|
+
@dataclass
|
|
150
|
+
class Outcome:
|
|
151
|
+
hook: Hook
|
|
152
|
+
code: int
|
|
153
|
+
output: str
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
@dataclass
|
|
157
|
+
class HookSet:
|
|
158
|
+
hooks: list[Hook] = field(default_factory=list)
|
|
159
|
+
problems: list[str] = field(default_factory=list)
|
|
160
|
+
|
|
161
|
+
def add(self, data: dict, source: str) -> None:
|
|
162
|
+
entries = data.get("hooks") or []
|
|
163
|
+
if not isinstance(entries, list):
|
|
164
|
+
raise HookError(f"{source}: hooks must be [[hooks]] tables")
|
|
165
|
+
for entry in entries:
|
|
166
|
+
event, command = entry.get("event"), entry.get("command")
|
|
167
|
+
if event not in EVENTS or not isinstance(command, str) or not command.strip():
|
|
168
|
+
self.problems.append(f"{source}: a hook needs an event ({', '.join(EVENTS)}) and a command")
|
|
169
|
+
continue
|
|
170
|
+
self.hooks.append(
|
|
171
|
+
Hook(
|
|
172
|
+
event=event,
|
|
173
|
+
command=command,
|
|
174
|
+
source=source,
|
|
175
|
+
tools=list(entry.get("tools") or []),
|
|
176
|
+
paths=list(entry.get("paths") or []),
|
|
177
|
+
timeout=float(entry.get("timeout") or 60),
|
|
178
|
+
feedback=bool(entry.get("feedback", False)),
|
|
179
|
+
)
|
|
180
|
+
)
|
|
181
|
+
|
|
182
|
+
def for_event(self, event: str, tool: str = "", path: str = "") -> list[Hook]:
|
|
183
|
+
return [h for h in self.hooks if h.event == event and h.applies(tool, path)]
|
|
184
|
+
|
|
185
|
+
def run(self, event: str, payload: dict, cwd: Path, tool: str = "", path: str = "") -> list[Outcome]:
|
|
186
|
+
outcomes = []
|
|
187
|
+
for hook in self.for_event(event, tool, path):
|
|
188
|
+
outcomes.append(run_hook(hook, {"event": event, **payload}, cwd, tool, path))
|
|
189
|
+
return outcomes
|
|
190
|
+
|
|
191
|
+
def notify(self, message: str, cwd: Path) -> None:
|
|
192
|
+
"""Notification hooks, in the background: lcode doesn't wait for them."""
|
|
193
|
+
hooks = self.for_event("notification")
|
|
194
|
+
if hooks:
|
|
195
|
+
|
|
196
|
+
def go() -> None:
|
|
197
|
+
for hook in hooks:
|
|
198
|
+
run_hook(hook, {"event": "notification", "message": message}, cwd, "", "")
|
|
199
|
+
|
|
200
|
+
threading.Thread(target=go, daemon=True).start()
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def run_hook(hook: Hook, payload: dict, cwd: Path, tool: str, path: str) -> Outcome:
|
|
204
|
+
command = hook.command.replace("{path}", shlex.quote(path) if path else "").replace("{tool}", shlex.quote(tool))
|
|
205
|
+
env = {**os.environ, "LCODE_EVENT": payload.get("event", ""), "LCODE_TOOL": tool, "LCODE_PATH": path}
|
|
206
|
+
try:
|
|
207
|
+
r = subprocess.run(
|
|
208
|
+
["bash", "-c", command],
|
|
209
|
+
input=json.dumps(payload, default=str),
|
|
210
|
+
capture_output=True,
|
|
211
|
+
text=True,
|
|
212
|
+
timeout=hook.timeout,
|
|
213
|
+
cwd=cwd,
|
|
214
|
+
env=env,
|
|
215
|
+
)
|
|
216
|
+
except subprocess.TimeoutExpired:
|
|
217
|
+
return Outcome(hook, 124, f"the hook took longer than {hook.timeout:.0f}s and was stopped")
|
|
218
|
+
except OSError as e:
|
|
219
|
+
return Outcome(hook, 127, str(e))
|
|
220
|
+
output = (r.stdout + ("\n" if r.stdout and r.stderr else "") + r.stderr).strip()
|
|
221
|
+
return Outcome(hook, r.returncode, output[:MAX_OUTPUT])
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
# ----------------------------------------------------------------------------- loading
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def load(cwd: Path, trust_project: bool) -> tuple[HookSet, Rules]:
|
|
228
|
+
"""The user's hooks and rules, plus the repository's when its settings are approved."""
|
|
229
|
+
hooks, rules = HookSet(), Rules()
|
|
230
|
+
sources = [(config.CONFIG_PATH, str(config.CONFIG_PATH))]
|
|
231
|
+
if trust_project:
|
|
232
|
+
project = project_settings(cwd)
|
|
233
|
+
sources.append((project, str(project)))
|
|
234
|
+
for path, label in sources:
|
|
235
|
+
try:
|
|
236
|
+
data = read_toml(path)
|
|
237
|
+
hooks.add(data, label)
|
|
238
|
+
rules.add(data, label)
|
|
239
|
+
except HookError as e:
|
|
240
|
+
hooks.problems.append(str(e))
|
|
241
|
+
return hooks, rules
|
lcode/permissions.py
CHANGED
|
@@ -64,9 +64,22 @@ def is_read_only(command: str) -> bool:
|
|
|
64
64
|
|
|
65
65
|
class Permissions:
|
|
66
66
|
def __init__(self, console: Console, mode: str = "ask"):
|
|
67
|
+
from lcode.hooks import Rules
|
|
68
|
+
|
|
67
69
|
self.console = console
|
|
68
70
|
self.mode = mode
|
|
69
71
|
self.always: set[str] = set()
|
|
72
|
+
self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
|
|
73
|
+
self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
|
|
74
|
+
|
|
75
|
+
def rule(self, kind: str, target: str) -> tuple[bool, str] | None:
|
|
76
|
+
"""A rule's verdict on an action: (allowed, message for the model), or None when no rule matches."""
|
|
77
|
+
verdict, which = self.rules.check(kind, target) if target else (None, "")
|
|
78
|
+
if verdict == "deny":
|
|
79
|
+
return False, f"A deny rule blocks this: {which}. Don't try it another way; tell the user if it's needed."
|
|
80
|
+
if verdict == "allow":
|
|
81
|
+
return True, ""
|
|
82
|
+
return None
|
|
70
83
|
|
|
71
84
|
def cycle(self) -> None:
|
|
72
85
|
self.mode = PERMISSION_MODES[(PERMISSION_MODES.index(self.mode) + 1) % len(PERMISSION_MODES)]
|
|
@@ -76,13 +89,18 @@ class Permissions:
|
|
|
76
89
|
return False
|
|
77
90
|
return not (kind == "edit" and self.mode == "auto-edit")
|
|
78
91
|
|
|
79
|
-
def request(self, key: str, kind: str, title: str, body: RenderableType) -> tuple[bool, str]:
|
|
92
|
+
def request(self, key: str, kind: str, title: str, body: RenderableType, target: str = "") -> tuple[bool, str]:
|
|
80
93
|
"""Ask the user. kind is 'edit', 'bash', 'web', 'mcp' or 'memory'. Returns (allowed, message for the model).
|
|
81
94
|
|
|
82
95
|
Memory notes follow the memory setting rather than the mode: with `memory = ask`, even yolo asks.
|
|
83
96
|
"""
|
|
97
|
+
verdict = self.rule(kind, target)
|
|
98
|
+
if verdict is not None:
|
|
99
|
+
return verdict
|
|
84
100
|
if not self.needs_prompt(key, kind):
|
|
85
101
|
return True, ""
|
|
102
|
+
if self.on_prompt:
|
|
103
|
+
self.on_prompt(title)
|
|
86
104
|
self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
|
|
87
105
|
scope = {"edit": "file edits", "memory": "memory notes"}.get(kind) or key.split(":", 1)[-1]
|
|
88
106
|
if kind == "mcp":
|
lcode/repl.py
CHANGED
|
@@ -10,6 +10,7 @@ import shlex
|
|
|
10
10
|
import shutil
|
|
11
11
|
import subprocess
|
|
12
12
|
import threading
|
|
13
|
+
import time
|
|
13
14
|
from pathlib import Path
|
|
14
15
|
|
|
15
16
|
from prompt_toolkit import PromptSession
|
|
@@ -755,7 +756,11 @@ def web_status(agent: Agent) -> str:
|
|
|
755
756
|
return f"{agent.settings.web} · {search}"
|
|
756
757
|
|
|
757
758
|
|
|
759
|
+
LONG_REQUEST = 30 # seconds: a request this long sends a notification when it's done (notification hooks)
|
|
760
|
+
|
|
761
|
+
|
|
758
762
|
def run_safely(agent: Agent, text: str) -> None:
|
|
763
|
+
started = time.monotonic()
|
|
759
764
|
try:
|
|
760
765
|
agent.run_turn(text)
|
|
761
766
|
except KeyboardInterrupt:
|
|
@@ -764,6 +769,17 @@ def run_safely(agent: Agent, text: str) -> None:
|
|
|
764
769
|
agent.console.print(f"[red]{e}[/]")
|
|
765
770
|
finally:
|
|
766
771
|
agent.save()
|
|
772
|
+
if time.monotonic() - started > LONG_REQUEST:
|
|
773
|
+
agent.hooks.notify(f"lcode finished: {sessions.title_from([{'role': 'user', 'content': text}])}", agent.cwd)
|
|
774
|
+
|
|
775
|
+
|
|
776
|
+
def session_start(agent: Agent) -> None:
|
|
777
|
+
"""session_start hooks: their output is shown, and with feedback = true also given to the model."""
|
|
778
|
+
for outcome in agent.hooks.run("session_start", {"cwd": str(agent.cwd), "session": agent.session_id}, agent.cwd):
|
|
779
|
+
if outcome.output:
|
|
780
|
+
agent.console.print(Text(f"session_start hook: {outcome.output[:1000]}", style="dim"))
|
|
781
|
+
if outcome.hook.feedback and outcome.output:
|
|
782
|
+
agent.messages[0]["content"] += f"\n# From a session_start hook\n{outcome.output}\n"
|
|
767
783
|
|
|
768
784
|
|
|
769
785
|
def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
|
|
@@ -1000,6 +1016,7 @@ def repl(agent: Agent, prompt: str | None, hardware: Hardware, cont: bool = Fals
|
|
|
1000
1016
|
info = choose_session(agent, resume)
|
|
1001
1017
|
if info:
|
|
1002
1018
|
resume_session(agent, info)
|
|
1019
|
+
session_start(agent)
|
|
1003
1020
|
if prompt:
|
|
1004
1021
|
run_safely(agent, prompt)
|
|
1005
1022
|
return
|
lcode/subagents.py
CHANGED
|
@@ -368,14 +368,24 @@ class Relay:
|
|
|
368
368
|
def mode(self) -> str:
|
|
369
369
|
return self.perms.mode
|
|
370
370
|
|
|
371
|
-
|
|
371
|
+
@property
|
|
372
|
+
def rules(self):
|
|
373
|
+
return self.perms.rules
|
|
374
|
+
|
|
375
|
+
def rule(self, kind: str, target: str):
|
|
376
|
+
return self.perms.rule(kind, target)
|
|
377
|
+
|
|
378
|
+
def request(self, key: str, kind: str, title: str, body, target: str = "") -> tuple[bool, str]:
|
|
379
|
+
verdict = self.perms.rule(kind, target)
|
|
380
|
+
if verdict is not None:
|
|
381
|
+
return verdict
|
|
372
382
|
if not self.perms.needs_prompt(key, kind):
|
|
373
383
|
return True, ""
|
|
374
384
|
title = f"{self.label} › {title}"
|
|
375
385
|
if self.broker and threading.current_thread() is not threading.main_thread():
|
|
376
|
-
return self.broker.ask(key, kind, title, body)
|
|
386
|
+
return self.broker.ask(key, kind, title, body, target)
|
|
377
387
|
with self.pause():
|
|
378
|
-
return self.perms.request(key, kind, title, body)
|
|
388
|
+
return self.perms.request(key, kind, title, body, target)
|
|
379
389
|
|
|
380
390
|
|
|
381
391
|
# ----------------------------------------------------------------------------- one subagent
|
|
@@ -543,7 +553,8 @@ class Run:
|
|
|
543
553
|
shown = patch if len(patch) <= 12_000 else patch[:12_000] + "\n... (diff truncated for display)\n"
|
|
544
554
|
body = Syntax(shown, "diff", theme="monokai", word_wrap=True)
|
|
545
555
|
title = f"Apply the {self.kind.name} agent's changes to {len(files)} file{'' if len(files) == 1 else 's'}"
|
|
546
|
-
|
|
556
|
+
targets = " ".join(files)
|
|
557
|
+
ok, feedback = parent.perms.request("edit", "edit", title, body, targets)
|
|
547
558
|
if not ok:
|
|
548
559
|
return f"[Its changes were NOT applied. {feedback}]"
|
|
549
560
|
parent.checkpoint()
|
lcode/tools.py
CHANGED
|
@@ -393,6 +393,15 @@ class Toolbox:
|
|
|
393
393
|
raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
|
|
394
394
|
return p
|
|
395
395
|
|
|
396
|
+
def project_rel(self, p: Path) -> str:
|
|
397
|
+
"""A path relative to the repository's root, as permission rules and hooks see it."""
|
|
398
|
+
from lcode.checkpoints import work_tree_for
|
|
399
|
+
|
|
400
|
+
try:
|
|
401
|
+
return p.resolve().relative_to(work_tree_for(self.agent.cwd)).as_posix()
|
|
402
|
+
except ValueError:
|
|
403
|
+
return str(p)
|
|
404
|
+
|
|
396
405
|
def rel(self, p: Path) -> str:
|
|
397
406
|
try:
|
|
398
407
|
return str(p.relative_to(self.agent.cwd)) or "."
|
|
@@ -400,6 +409,32 @@ class Toolbox:
|
|
|
400
409
|
return str(p)
|
|
401
410
|
|
|
402
411
|
def run(self, name: str, args: dict) -> str:
|
|
412
|
+
hooks = self.agent.hooks
|
|
413
|
+
if not hooks.hooks:
|
|
414
|
+
return self._run(name, args)
|
|
415
|
+
path = str(self.resolve(args["path"])) if isinstance(args.get("path"), str) and args.get("path") else ""
|
|
416
|
+
payload = {"tool": name, "arguments": args, "path": path, "cwd": str(self.agent.cwd)}
|
|
417
|
+
for outcome in hooks.run("before_tool", payload, self.agent.cwd, name, path):
|
|
418
|
+
if outcome.code == 2: # the hook blocks the call
|
|
419
|
+
self.console.print(Text(f" ⎿ blocked by a hook: {outcome.output[:200]}", style="yellow"))
|
|
420
|
+
return f"Error: a hook blocked this {name} call: {outcome.output or 'no reason given'}"
|
|
421
|
+
if outcome.code != 0:
|
|
422
|
+
self.console.print(
|
|
423
|
+
Text(f" ⎿ a before_tool hook failed ({outcome.code}): {outcome.output[:200]}", style="yellow")
|
|
424
|
+
)
|
|
425
|
+
result = self._run(name, args)
|
|
426
|
+
notes = []
|
|
427
|
+
for outcome in hooks.run("after_tool", {**payload, "result": result[:4000]}, self.agent.cwd, name, path):
|
|
428
|
+
if outcome.code != 0 or outcome.hook.feedback:
|
|
429
|
+
status = "" if outcome.code == 0 else f", exit code {outcome.code}"
|
|
430
|
+
notes.append(
|
|
431
|
+
f"[A hook ran after this ({outcome.hook.command[:60]}{status}):\n{outcome.output or '(no output)'}]"
|
|
432
|
+
)
|
|
433
|
+
if outcome.code != 0:
|
|
434
|
+
self.console.print(Text(f" ⎿ hook exit code {outcome.code}: {outcome.output[:200]}", style="yellow"))
|
|
435
|
+
return result + "".join("\n" + n for n in notes)
|
|
436
|
+
|
|
437
|
+
def _run(self, name: str, args: dict) -> str:
|
|
403
438
|
allowed = self.agent.allowed_tools
|
|
404
439
|
if allowed is not None and name not in allowed:
|
|
405
440
|
return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
|
|
@@ -540,7 +575,8 @@ class Toolbox:
|
|
|
540
575
|
if not mcp.allowed(state, tool):
|
|
541
576
|
body = Syntax(json.dumps(arguments, indent=2, ensure_ascii=False), "json", theme="monokai", word_wrap=True)
|
|
542
577
|
title = f"Use {state.name} › {tool['name']}"
|
|
543
|
-
|
|
578
|
+
key = f"mcp:{state.name}:{tool['name']}"
|
|
579
|
+
ok, feedback = self.agent.perms.request(key, "mcp", title, body, f"{state.name}:{tool['name']}")
|
|
544
580
|
if not ok:
|
|
545
581
|
return feedback
|
|
546
582
|
if mcp.needs_gpu(state, tool):
|
|
@@ -682,7 +718,7 @@ class Toolbox:
|
|
|
682
718
|
preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
|
|
683
719
|
body = Syntax(preview, Syntax.guess_lexer(str(p), content), theme="monokai", line_numbers=True)
|
|
684
720
|
verb = "Overwrite" if exists else "Create"
|
|
685
|
-
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body)
|
|
721
|
+
ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
|
|
686
722
|
if not ok:
|
|
687
723
|
return feedback
|
|
688
724
|
before = p.read_text(errors="replace") if exists else None
|
|
@@ -718,7 +754,8 @@ class Toolbox:
|
|
|
718
754
|
)
|
|
719
755
|
else:
|
|
720
756
|
new_text = text.replace(old_string, new_string) if replace_all else text.replace(old_string, new_string, 1)
|
|
721
|
-
|
|
757
|
+
diff = self._diff(p, text, new_text)
|
|
758
|
+
ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", diff, self.project_rel(p))
|
|
722
759
|
if not ok:
|
|
723
760
|
return feedback
|
|
724
761
|
self.agent.checkpoint()
|
|
@@ -745,10 +782,13 @@ class Toolbox:
|
|
|
745
782
|
"you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
|
|
746
783
|
"time without pipes into other programs, redirection or chaining"
|
|
747
784
|
)
|
|
748
|
-
|
|
785
|
+
verdict = self.agent.perms.rule("bash", command)
|
|
786
|
+
if verdict is not None and not verdict[0]: # deny rules apply to read-only commands too
|
|
787
|
+
return verdict[1]
|
|
788
|
+
if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit") and verdict is None:
|
|
749
789
|
body = Syntax(command, "bash", theme="monokai", word_wrap=True)
|
|
750
790
|
where = "in the sandbox" if sandbox else f"in {self.agent.cwd}"
|
|
751
|
-
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body)
|
|
791
|
+
ok, feedback = self.agent.perms.request(bash_key(command), "bash", f"Run command ({where})", body, command)
|
|
752
792
|
if not ok:
|
|
753
793
|
return feedback
|
|
754
794
|
if not is_read_only(command):
|
|
@@ -842,7 +882,7 @@ class Toolbox:
|
|
|
842
882
|
if settings.web == "off":
|
|
843
883
|
return False, "Web access is turned off (web = off)."
|
|
844
884
|
if settings.web == "ask":
|
|
845
|
-
return self.agent.perms.request(key, "web", title, Text(detail))
|
|
885
|
+
return self.agent.perms.request(key, "web", title, Text(detail), key.removeprefix("web:"))
|
|
846
886
|
return True, ""
|
|
847
887
|
|
|
848
888
|
def t_web_search(self, query: str, max_results: int = 5) -> str:
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
Metadata-Version: 2.5
|
|
2
2
|
Name: lcode-cli
|
|
3
|
-
Version: 0.
|
|
3
|
+
Version: 0.15.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/
|
|
@@ -89,6 +89,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
89
89
|
and sees the errors an edit introduced right away.
|
|
90
90
|
- **Commands and skills.** Your own `/commands` as prompt templates, and skills in the
|
|
91
91
|
[Agent Skills](https://agentskills.io) format that the model loads only when a task needs them.
|
|
92
|
+
- **Hooks and rules.** Format every edited file, block force-pushes, run the tests after each
|
|
93
|
+
request: shell hooks on lcode's events, and allow and deny lists that hold even in `yolo` mode.
|
|
92
94
|
- **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
|
|
93
95
|
you approve, edit or send it back before anything changes.
|
|
94
96
|
- **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
|
|
@@ -206,7 +208,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
|
|
|
206
208
|
|
|
207
209
|
See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
|
|
208
210
|
|
|
209
|
-
- [
|
|
211
|
+
- [More local backends: LM Studio, llama.cpp, vLLM and MLX](https://github.com/nasser1941/lcode/issues/47)
|
|
212
|
+
- [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
|
|
210
213
|
- More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
|
|
211
214
|
|
|
212
215
|
## Contributing
|
|
@@ -1,31 +1,32 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=nt1m5TjCnM-J0wx2xHbNb3L4c1vNJmM3nIma1bcOi5E,118
|
|
2
2
|
lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
|
|
3
|
-
lcode/agent.py,sha256=
|
|
3
|
+
lcode/agent.py,sha256=7eWmKnxRAYEvGYGkukmd2EGfWg_LYhEQdBJQljMK_Uk,46113
|
|
4
4
|
lcode/bench.py,sha256=7XHJZmRVUt3JYYEyIIMX9fcqOFIvfKB9Iy-2aPo00VU,40406
|
|
5
5
|
lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
|
|
6
6
|
lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
|
|
7
7
|
lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
|
|
8
|
-
lcode/cli.py,sha256=
|
|
8
|
+
lcode/cli.py,sha256=bcASJ0qpSbAi-rmqqLqpySHtckf6rQ777CjB82ll8C0,37091
|
|
9
9
|
lcode/codesearch.py,sha256=_Z7D_Snpl0NwfXk6P4yUJ0VmwLKCy74YLc52DjUUdTU,10510
|
|
10
|
-
lcode/config.py,sha256=
|
|
10
|
+
lcode/config.py,sha256=5JdLA04cxB7gEf6JGJYbIlqIgjo9tNNiT26osEdJ9zI,9190
|
|
11
11
|
lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
|
|
12
|
-
lcode/extensions.py,sha256=
|
|
12
|
+
lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
|
|
13
13
|
lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
|
|
14
14
|
lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
|
|
15
|
+
lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
|
|
15
16
|
lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
|
|
16
17
|
lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
|
|
17
18
|
lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
|
|
18
19
|
lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
|
|
19
20
|
lcode/ollama.py,sha256=edSckfbtK0iSH-Pm-IpUHO_73nPaRokh7cHiDBSKvWU,7594
|
|
20
|
-
lcode/permissions.py,sha256=
|
|
21
|
+
lcode/permissions.py,sha256=bSDpkSzq1tozC3LgzAzXmJX_DGRxoY9a3Kdx-AFQ3nI,5426
|
|
21
22
|
lcode/planning.py,sha256=hZjVka7i9TswJrXLbjbZJ0PBc6zHpmO64JqMiaFYnMQ,6110
|
|
22
23
|
lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
|
|
23
|
-
lcode/repl.py,sha256=
|
|
24
|
+
lcode/repl.py,sha256=l3LHCNRZD7Kk03k5hk6GJKrm62ToQT9FPjKtW3xwsAc,45540
|
|
24
25
|
lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
|
|
25
26
|
lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
|
|
26
27
|
lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
|
|
27
|
-
lcode/subagents.py,sha256=
|
|
28
|
-
lcode/tools.py,sha256=
|
|
28
|
+
lcode/subagents.py,sha256=kfDICxAYd4i9LtBFxTO9tdvbAB5JzF_iQXUUK9i6yc4,28813
|
|
29
|
+
lcode/tools.py,sha256=ris8gXJ2phFY9Fr4UnUVkuyqjMRWMWONZc7onO7O-9c,41904
|
|
29
30
|
lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
|
|
30
31
|
lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
|
|
31
32
|
lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
|
|
@@ -43,8 +44,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
|
|
|
43
44
|
lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
|
|
44
45
|
lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
|
|
45
46
|
lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
|
|
46
|
-
lcode_cli-0.
|
|
47
|
-
lcode_cli-0.
|
|
48
|
-
lcode_cli-0.
|
|
49
|
-
lcode_cli-0.
|
|
50
|
-
lcode_cli-0.
|
|
47
|
+
lcode_cli-0.15.0.dist-info/METADATA,sha256=BSaWKw3B8zJZ55eweihAEyScAXH3KT3fHgfBTdTQJQk,12040
|
|
48
|
+
lcode_cli-0.15.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
49
|
+
lcode_cli-0.15.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
|
|
50
|
+
lcode_cli-0.15.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
51
|
+
lcode_cli-0.15.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|
|
File without changes
|