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 CHANGED
@@ -1,3 +1,3 @@
1
1
  """lcode — a local-first terminal coding agent powered by open-weight models via Ollama."""
2
2
 
3
- __version__ = "0.14.0"
3
+ __version__ = "0.15.0"
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 (commands still ask first, unless "
382
- "you allow them).[/]"
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
- def request(self, key: str, kind: str, title: str, body) -> tuple[bool, str]:
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
- ok, feedback = parent.perms.request("edit", "edit", title, body)
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
- ok, feedback = self.agent.perms.request(f"mcp:{state.name}:{tool['name']}", "mcp", title, body)
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
- ok, feedback = self.agent.perms.request("edit", "edit", f"Edit {self.rel(p)}", self._diff(p, text, new_text))
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
- if not is_read_only(command) and not (sandbox and self.agent.perms.mode == "auto-edit"):
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.14.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
- - [Hooks and permission rules](https://github.com/nasser1941/lcode/issues/43)
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=wYa3zAlm3RPawB49b0i_I_WUqgewgwXMs6xEVHHHG64,118
1
+ lcode/__init__.py,sha256=nt1m5TjCnM-J0wx2xHbNb3L4c1vNJmM3nIma1bcOi5E,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=T6MZmAW5yEGOOU29SdyYRi5veJxDDPWEKvobxglhuzs,45428
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=q-sHK0WbX-wcQ0yU4RBVL7NERgM04roUlR7anVlEQFA,36204
8
+ lcode/cli.py,sha256=bcASJ0qpSbAi-rmqqLqpySHtckf6rQ777CjB82ll8C0,37091
9
9
  lcode/codesearch.py,sha256=_Z7D_Snpl0NwfXk6P4yUJ0VmwLKCy74YLc52DjUUdTU,10510
10
- lcode/config.py,sha256=Epm5Zh6b1fUYtI6zdvRqvnWMD4u3rCJ0tyhbqEIjG5E,8883
10
+ lcode/config.py,sha256=5JdLA04cxB7gEf6JGJYbIlqIgjo9tNNiT26osEdJ9zI,9190
11
11
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
12
- lcode/extensions.py,sha256=1MkCmfvQvcJt1XaqIEOYUruCTsrfQVI5TXOTWBkkJtI,15882
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=m8YNtCladVAmXB20ca8AwyfEOzHKS557B0SOvx1oHeM,4528
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=Dnm4H0FGbD-LbXUYhF13tL4WTTVf_rzDIuj-Gse9vdU,44647
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=QYt5kzUF1lB45dn2oaTBkOUaOJrcyC0hvo5g7DARcSA,28471
28
- lcode/tools.py,sha256=rwH0V9uMQSqpb6eo1B5gVSwhw-uPJ_1ksBShvsoItfE,39584
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.14.0.dist-info/METADATA,sha256=I9bhdO3mQPt6M3vAvy6xYNGjbPed7BveoyIl1n1eohc,11709
47
- lcode_cli-0.14.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
48
- lcode_cli-0.14.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
49
- lcode_cli-0.14.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
50
- lcode_cli-0.14.0.dist-info/RECORD,,
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,,