lcode-cli 0.9.0__py3-none-any.whl → 0.10.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.9.0"
3
+ __version__ = "0.10.0"
lcode/agent.py CHANGED
@@ -21,7 +21,7 @@ from rich.markup import escape
21
21
  from rich.panel import Panel
22
22
  from rich.text import Text
23
23
 
24
- from lcode import catalog, limits, planning, sessions, subagents, vision, web
24
+ from lcode import catalog, extensions, limits, planning, sessions, subagents, vision, web
25
25
  from lcode import memory as memory_notes
26
26
  from lcode.checkpoints import Checkpoints
27
27
  from lcode.config import format_tokens
@@ -78,7 +78,7 @@ SYSTEM_PROMPT = """You are lcode, an autonomous software-engineering agent runni
78
78
 
79
79
  # Top-level layout of the working directory
80
80
  {tree}
81
- {web}{agents}{project}{notes}"""
81
+ {web}{agents}{skills}{project}{notes}"""
82
82
 
83
83
  WEB_PROMPT = """
84
84
  # Web access
@@ -171,6 +171,8 @@ class Settings:
171
171
  vision_model: str = "auto" # auto, off or an Ollama model that can see images
172
172
  memory: str = "off" # off, ask or auto: notes that carry over to later sessions (lcode.memory)
173
173
  subagents: bool = False # the agent tool (lcode.subagents)
174
+ trust_project: bool = False # use the repository's own commands, skills and agents (lcode.extensions)
175
+ skills: str = "off" # all, lcode (only lcode's own skill folders) or off
174
176
  max_parallel_agents: int = 1
175
177
 
176
178
 
@@ -200,6 +202,8 @@ class Agent:
200
202
  self._agent_types: tuple[Path, dict[str, subagents.AgentType], list[str]] | None = None
201
203
  self.agent_runs: list[subagents.Record] = [] # subagents run in this session, for /agents
202
204
  self.plan = "" # the plan the user approved; kept through compaction
205
+ self._extensions: tuple[Path, extensions.Extensions] | None = None
206
+ self.skills_loaded: set[str] = set() # skills whose instructions are in the conversation
203
207
  # Set on subagents (see lcode.subagents):
204
208
  self.allowed_tools: set[str] | None = None # None: every tool
205
209
  self.read_only = False # only read-only shell commands
@@ -236,8 +240,15 @@ class Agent:
236
240
  notes=self.memory().prompt(self.settings.context) if self.settings.memory != "off" else "",
237
241
  web=self.web_prompt(),
238
242
  agents=self.agents_prompt(),
243
+ skills=extensions.skills_prompt(self.extensions().skills, self.settings.context),
239
244
  )
240
245
 
246
+ def extensions(self) -> extensions.Extensions:
247
+ """Custom commands and skills for the current folder (a repository's only once approved)."""
248
+ if self._extensions is None or self._extensions[0] != self.cwd:
249
+ self._extensions = (self.cwd, extensions.load(self.cwd, self.settings.trust_project, self.settings.skills))
250
+ return self._extensions[1]
251
+
241
252
  def agents_prompt(self) -> str:
242
253
  if not self.settings.subagents:
243
254
  return ""
@@ -252,7 +263,7 @@ class Agent:
252
263
  def agent_types(self) -> dict[str, subagents.AgentType]:
253
264
  """Built-in and custom agent types for the current folder."""
254
265
  if self._agent_types is None or self._agent_types[0] != self.cwd:
255
- types, problems = subagents.load_types(self.cwd)
266
+ types, problems = subagents.load_types(self.cwd, self.settings.trust_project)
256
267
  self._agent_types = (self.cwd, types, problems)
257
268
  return self._agent_types[1]
258
269
 
@@ -290,6 +301,8 @@ class Agent:
290
301
  schemas.append(subagents.schema(self.agent_types(), self.settings.max_parallel_agents))
291
302
  if self.planning():
292
303
  schemas.append(PRESENT_PLAN_SCHEMA)
304
+ if self.extensions().skills:
305
+ schemas.append(extensions.schema(self.extensions().skills))
293
306
  if self.mcp:
294
307
  schemas += self.mcp.schemas(self.settings.context)
295
308
  if self.allowed_tools is not None:
@@ -409,6 +422,7 @@ class Agent:
409
422
 
410
423
  def new_session(self) -> None:
411
424
  self.reset()
425
+ self.skills_loaded.clear()
412
426
  self.session_id = self.new_session_id()
413
427
  self.session_name = ""
414
428
  self.session_title = ""
@@ -606,6 +620,8 @@ class Agent:
606
620
  return f"list_dir({args.get('path', '.')})"
607
621
  if name == "memory":
608
622
  return f"memory({' '.join(str(args.get(k) or '') for k in ('action', 'name')).strip()})"
623
+ if name == "skill":
624
+ return f"skill({args.get('name', '')})"
609
625
  if name == "agent":
610
626
  return f"agent({args.get('type', '')}: {args.get('description') or str(args.get('task', ''))[:50]})"
611
627
  if name == "bash":
@@ -787,6 +803,12 @@ class Agent:
787
803
  ]
788
804
  if self.plan: # the approved plan stays word for word
789
805
  self.messages[1]["content"] += f"\n\n[The plan the user approved; keep following it]\n\n{self.plan}"
806
+ if self.skills_loaded: # their instructions were in the old messages: load them again when needed
807
+ names = ", ".join(sorted(self.skills_loaded))
808
+ self.messages[1]["content"] += (
809
+ f"\n\n[Skills in use before the summary: {names}. Load them again with the skill tool if you still need them.]"
810
+ )
811
+ self.skills_loaded.clear()
790
812
  self.ctx_used = sum(len(m["content"]) for m in self.messages) // 3
791
813
  self.reflected = len(self.messages)
792
814
  self.console.print(Panel(Markdown(summary), title="Compacted summary", border_style="blue"))
lcode/cli.py CHANGED
@@ -367,6 +367,20 @@ def cmd_doctor(args) -> None:
367
367
  line("", f"{n} × {slots[1]} context may not fit in memory here; {slots[0]} would", None)
368
368
  for problem in problems:
369
369
  line("", problem, None)
370
+ from lcode import extensions
371
+
372
+ project_state = extensions.status(Path.cwd())
373
+ ext = extensions.load(Path.cwd(), include_project=project_state == "approved", skills_from=cfg["skills"])
374
+ found = f"{len(ext.commands)} command(s), {len(ext.skills)} skill(s)"
375
+ note = {
376
+ "none": "",
377
+ "approved": " · this repository's are approved",
378
+ "new": " · this repository brings its own: approve them when lcode asks",
379
+ "changed": " · this repository's changed since you approved them: lcode asks again",
380
+ }[project_state]
381
+ line("Extras", found + note, None if project_state in ("new", "changed") else True)
382
+ for problem in ext.problems:
383
+ line("", problem, None)
370
384
  if cfg["memory"] == "off":
371
385
  line("Memory", "off (lcode config set memory ask)", True)
372
386
  else:
@@ -528,6 +542,9 @@ def cmd_chat(args) -> None:
528
542
  num_batch = spec.num_batch
529
543
  else: # the tuned batch size assumes the text-only variant; the vision projector needs that VRAM
530
544
  console.print(f"[dim]Tip: run `lcode setup {spec.key}` once to create the faster text-only variant.[/]")
545
+ from lcode import extensions
546
+
547
+ trust_project = extensions.trust_project(cwd, console, interactive=not args.prompt)
531
548
  settings = Settings(
532
549
  model=model,
533
550
  context=context,
@@ -547,6 +564,8 @@ def cmd_chat(args) -> None:
547
564
  memory="off" if args.no_memory else cfg["memory"],
548
565
  subagents=cfg["subagents"],
549
566
  max_parallel_agents=cfg["max_parallel_agents"],
567
+ trust_project=trust_project,
568
+ skills=cfg["skills"],
550
569
  )
551
570
  agent = Agent(ollama, settings, cwd, console=console)
552
571
  agent.interactive = not args.prompt
lcode/config.py CHANGED
@@ -27,6 +27,7 @@ SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
27
27
  MCP_TOOL_MODES = ("auto", "direct", "search")
28
28
  SANDBOX_ENGINES = ("off", "docker", "podman")
29
29
  MEMORY_MODES = ("off", "ask", "auto")
30
+ SKILL_SOURCES = ("all", "lcode", "off")
30
31
 
31
32
  # key -> (default, type, help)
32
33
  SETTINGS: dict[str, tuple[object, type, str]] = {
@@ -47,6 +48,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
47
48
  "vision_model": ("auto", str, "model that looks at images: auto | off | an Ollama model with vision"),
48
49
  "mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
49
50
  "memory": ("ask", str, "notes that carry over to later sessions: off | ask (confirm each) | auto"),
51
+ "skills": ("all", str, "skills to offer the model: all (also other agents' folders) | lcode | off"),
50
52
  "subagents": (True, bool, "let the model hand tasks to subagents that have their own context"),
51
53
  "max_parallel_agents": (1, int, "subagents that may run at the same time (more needs OLLAMA_NUM_PARALLEL)"),
52
54
  }
@@ -120,6 +122,10 @@ def coerce(key: str, value: object) -> object:
120
122
  if value not in MEMORY_MODES:
121
123
  raise ConfigError(f"memory must be one of {', '.join(MEMORY_MODES)}")
122
124
  return value
125
+ if key == "skills" and str(value).lower() not in SKILL_SOURCES:
126
+ raise ConfigError(f"skills must be one of {', '.join(SKILL_SOURCES)}")
127
+ if key == "skills":
128
+ return str(value).lower()
123
129
  if key == "mcp_tools" and value not in MCP_TOOL_MODES:
124
130
  raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
125
131
  if key == "search_backend" and value not in SEARCH_BACKENDS:
lcode/extensions.py ADDED
@@ -0,0 +1,405 @@
1
+ """Custom slash commands and skills, and the approval of those a repository brings.
2
+
3
+ Commands are prompt templates: `.lcode/commands/<name>.md` in a repository or
4
+ `~/.config/lcode/commands/<name>.md`, run as `/name args`. `$ARGUMENTS` in the template becomes
5
+ everything after the name, `$1`…`$9` single words. An optional frontmatter gives a description,
6
+ an argument hint for /help and the tools the model may use for that request.
7
+
8
+ Skills follow the Agent Skills format (https://agentskills.io): a folder with a SKILL.md
9
+ (frontmatter with name and description, then instructions) and any scripts or reference files next
10
+ to it. lcode looks in `.lcode/skills/`, `.agents/skills/` and `.claude/skills/` of the repository and
11
+ the same places in the user's home (`~/.config/lcode/skills/` for lcode's own). Only the names and
12
+ descriptions are in the system prompt; the instructions load when the model calls the skill tool for
13
+ a task that matches, or when the user runs `/name`. Bundled files are listed, not read, until needed.
14
+
15
+ A repository's commands, skills and agents can steer the model and bring scripts it may run, so
16
+ lcode asks before using them the first time, and again whenever they change.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import hashlib
22
+ import json
23
+ import os
24
+ import re
25
+ from dataclasses import dataclass
26
+ from pathlib import Path
27
+ from typing import TYPE_CHECKING
28
+
29
+ from rich.markup import escape
30
+
31
+ from lcode import config
32
+ from lcode.checkpoints import work_tree_for
33
+ from lcode.frontmatter import split
34
+
35
+ if TYPE_CHECKING:
36
+ from rich.console import Console
37
+
38
+ from lcode.agent import Agent
39
+
40
+ SKILL_FOLDERS = (".lcode/skills", ".agents/skills", ".claude/skills") # in a repository, first found wins
41
+ PROJECT_FOLDERS = (".lcode/commands", ".lcode/agents", *SKILL_FOLDERS) # what needs approval
42
+ MAX_SKILLS_PROMPT = 6000 # characters of skill descriptions in the system prompt (~2K tokens)
43
+ MAX_DESCRIPTION = 300 # characters of one skill's description in the system prompt
44
+ MIN_DESCRIPTION = 40 # with many skills, descriptions are cut down to this rather than skills left out
45
+ MAX_RESOURCES = 50
46
+ MAX_FILES = 2000 # files looked at when fingerprinting a repository's extensions
47
+ SKIP_DIRS = {".git", "node_modules", "__pycache__", ".venv", "venv"}
48
+
49
+ SKILLS_PROMPT = """
50
+ # Skills
51
+ Skills are instructions for specific kinds of tasks. When a task matches a skill's description, call the skill tool with its name first, then follow the instructions it returns. Paths in a skill are relative to its folder; use the absolute paths it gives.
52
+ {catalog}
53
+ """
54
+
55
+
56
+ class ExtensionError(ValueError):
57
+ pass
58
+
59
+
60
+ @dataclass(frozen=True)
61
+ class Command:
62
+ name: str
63
+ description: str
64
+ template: str
65
+ path: Path
66
+ scope: str # project | user
67
+ argument_hint: str = ""
68
+ tools: frozenset[str] | None = None
69
+
70
+ def render(self, args: str) -> str:
71
+ """The request: the template with $ARGUMENTS and $1…$9 filled in."""
72
+ words = args.split()
73
+ text = self.template
74
+ used = "$ARGUMENTS" in text or re.search(r"\$[1-9]", text)
75
+ text = text.replace("$ARGUMENTS", args)
76
+ text = re.sub(r"\$([1-9])", lambda m: words[int(m.group(1)) - 1] if int(m.group(1)) <= len(words) else "", text)
77
+ if args and not used:
78
+ text = f"{text.rstrip()}\n\n{args}"
79
+ return text.strip()
80
+
81
+
82
+ @dataclass(frozen=True)
83
+ class Skill:
84
+ name: str
85
+ description: str
86
+ path: Path # the SKILL.md
87
+ scope: str
88
+
89
+ @property
90
+ def folder(self) -> Path:
91
+ return self.path.parent
92
+
93
+ def instructions(self) -> str:
94
+ parts = split(self.path.read_text(errors="replace"))
95
+ return (parts[1] if parts else self.path.read_text(errors="replace")).strip()
96
+
97
+ def resources(self) -> list[str]:
98
+ found = []
99
+ for root, dirs, files in os.walk(self.folder):
100
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS and not d.startswith("."))
101
+ for name in sorted(files):
102
+ path = Path(root) / name
103
+ if path != self.path:
104
+ found.append(path.relative_to(self.folder).as_posix())
105
+ if len(found) > MAX_RESOURCES:
106
+ return found
107
+ return found
108
+
109
+
110
+ # ----------------------------------------------------------------------------- loading
111
+
112
+
113
+ def user_skill_folders(which: str = "all") -> list[Path]:
114
+ home = Path.home()
115
+ other_agents = [home / ".agents" / "skills", home / ".claude" / "skills"] if which == "all" else []
116
+ return [config.CONFIG_DIR / "skills", *other_agents]
117
+
118
+
119
+ def load_commands(cwd: Path, include_project: bool) -> tuple[dict[str, Command], list[str]]:
120
+ folders = [(config.CONFIG_DIR / "commands", "user")]
121
+ if include_project:
122
+ folders.append((work_tree_for(cwd) / ".lcode" / "commands", "project"))
123
+ commands: dict[str, Command] = {}
124
+ problems = []
125
+ for folder, scope in folders: # the repository's override the user's
126
+ for f in sorted(folder.glob("*.md")) if folder.is_dir() else []:
127
+ try:
128
+ commands[f.stem] = parse_command(f, scope)
129
+ except (ExtensionError, OSError) as e:
130
+ problems.append(f"{f}: {e}")
131
+ return commands, problems
132
+
133
+
134
+ def parse_command(path: Path, scope: str) -> Command:
135
+ from lcode.tools import TOOL_NAMES
136
+
137
+ name = path.stem
138
+ if not re.fullmatch(r"[a-z0-9][a-z0-9_-]*", name):
139
+ raise ExtensionError("the file name must be lowercase letters, digits, - or _")
140
+ text = path.read_text(errors="replace")
141
+ fields, body = split(text) or ({}, text)
142
+ if not body.strip():
143
+ raise ExtensionError("the command has no prompt")
144
+ tools = None
145
+ listed = fields.get("allowed-tools") or fields.get("tools")
146
+ if listed:
147
+ tools = frozenset(t for t in re.split(r"[,\s]+", listed.strip("[]")) if t)
148
+ unknown = tools - TOOL_NAMES
149
+ if unknown:
150
+ raise ExtensionError(
151
+ f"unknown tools: {', '.join(sorted(unknown))} (lcode's tools: {', '.join(sorted(TOOL_NAMES))})"
152
+ )
153
+ first = next((line.strip() for line in body.strip().splitlines() if line.strip()), "")
154
+ return Command(
155
+ name=name,
156
+ description=" ".join(fields.get("description", "").split()) or first[:80],
157
+ template=body.strip(),
158
+ path=path,
159
+ scope=scope,
160
+ argument_hint=fields.get("argument-hint", ""),
161
+ tools=tools,
162
+ )
163
+
164
+
165
+ def load_skills(cwd: Path, include_project: bool, which: str = "all") -> tuple[dict[str, Skill], list[str]]:
166
+ """Skills from lcode's folders and, with `which` = all, the folders other agents share."""
167
+ if which == "off":
168
+ return {}, []
169
+ places = [(folder, "user") for folder in user_skill_folders(which)]
170
+ if include_project:
171
+ root = work_tree_for(cwd)
172
+ places += [(root / folder, "project") for folder in (SKILL_FOLDERS if which == "all" else SKILL_FOLDERS[:1])]
173
+ skills: dict[str, Skill] = {}
174
+ problems = []
175
+ for folder, scope in places:
176
+ for skill_file in sorted(folder.glob("*/SKILL.md")) if folder.is_dir() else []:
177
+ try:
178
+ skill = parse_skill(skill_file, scope)
179
+ except (ExtensionError, OSError) as e:
180
+ problems.append(f"{skill_file}: {e}")
181
+ continue
182
+ existing = skills.get(skill.name)
183
+ if existing and existing.scope == scope:
184
+ if not same_file(existing.path, skill_file): # the same skill installed twice is fine
185
+ problems.append(f"{skill_file}: skipped, {existing.path} has the same name")
186
+ continue
187
+ skills[skill.name] = skill # a repository's skill replaces the user's
188
+ return skills, problems
189
+
190
+
191
+ def same_file(a: Path, b: Path) -> bool:
192
+ try:
193
+ return a.resolve() == b.resolve() or a.read_bytes() == b.read_bytes()
194
+ except OSError:
195
+ return False
196
+
197
+
198
+ def parse_skill(path: Path, scope: str) -> Skill:
199
+ parts = split(path.read_text(errors="replace"))
200
+ if not parts:
201
+ raise ExtensionError("SKILL.md needs a frontmatter with a name and a description")
202
+ fields = parts[0]
203
+ description = " ".join(fields.get("description", "").split())
204
+ if not description:
205
+ raise ExtensionError("the frontmatter needs a description")
206
+ # Lenient, like other clients: a name that breaks the format rules still works, as the folder name.
207
+ name = fields.get("name", "").strip() or path.parent.name
208
+ if not re.fullmatch(r"[a-z0-9]+(?:-[a-z0-9]+)*", name) or len(name) > 64:
209
+ name = path.parent.name
210
+ return Skill(name=name, description=description, path=path, scope=scope)
211
+
212
+
213
+ # ----------------------------------------------------------------------------- using skills
214
+
215
+
216
+ def skills_prompt(skills: dict[str, Skill], context: int = 262144) -> str:
217
+ """The skill catalog for the system prompt: within ~5% of the context window, every skill listed
218
+ (descriptions are shortened first), and only then cut off."""
219
+ if not skills:
220
+ return ""
221
+ budget = min(MAX_SKILLS_PROMPT, context * 3 // 20)
222
+ room = budget // len(skills) - 6
223
+ lines, used = [], 0
224
+ for skill in skills.values():
225
+ limit = max(MIN_DESCRIPTION, min(MAX_DESCRIPTION, room - len(skill.name)))
226
+ description = skill.description
227
+ if len(description) > limit:
228
+ description = description[: limit - 1].rstrip() + "…"
229
+ line = f"- {skill.name}: {description}"
230
+ if used + len(line) > budget:
231
+ lines.append(f"- … and {len(skills) - len(lines)} more (the skill tool lists them all)")
232
+ break
233
+ lines.append(line)
234
+ used += len(line) + 1
235
+ return SKILLS_PROMPT.format(catalog="\n".join(lines))
236
+
237
+
238
+ def schema(skills: dict[str, Skill]) -> dict:
239
+ from lcode.tools import _fn
240
+
241
+ return _fn(
242
+ "skill",
243
+ "Load a skill's instructions when the task matches its description (see Skills). Returns the "
244
+ "instructions, the skill's folder and the files in it.",
245
+ {"name": {"type": "string", "enum": sorted(skills)}},
246
+ ["name"],
247
+ )
248
+
249
+
250
+ def content(skill: Skill) -> str:
251
+ """What the model gets when a skill is activated: instructions, folder and bundled files."""
252
+ files = skill.resources()
253
+ listing = "\n".join(f" {f}" for f in files[:MAX_RESOURCES])
254
+ if len(files) > MAX_RESOURCES:
255
+ listing += "\n … (more files; list the folder to see them)"
256
+ return (
257
+ f'<skill_content name="{skill.name}">\n{skill.instructions()}\n\n'
258
+ f"Skill folder: {skill.folder}\nRelative paths in this skill are relative to that folder: use "
259
+ "absolute paths in tool calls.\n"
260
+ + (f"<skill_resources>\n{listing}\n</skill_resources>\n" if files else "")
261
+ + "</skill_content>"
262
+ )
263
+
264
+
265
+ def activate(agent: Agent, name: str) -> str:
266
+ skills = agent.extensions().skills
267
+ if name not in skills:
268
+ return f"Error: no skill {name!r}. Skills: {', '.join(sorted(skills)) or 'none'}"
269
+ if name in agent.skills_loaded:
270
+ return f"The {name} skill's instructions are already loaded above; follow them."
271
+ try:
272
+ text = content(skills[name])
273
+ except OSError as e:
274
+ return f"Error: can't read the {name} skill: {e}"
275
+ agent.skills_loaded.add(name)
276
+ return text
277
+
278
+
279
+ # ----------------------------------------------------------------------------- approving a repository's extensions
280
+
281
+
282
+ def approvals_path() -> Path:
283
+ return config.STATE_DIR / "extensions-approved.json"
284
+
285
+
286
+ def project_files(cwd: Path) -> tuple[Path, list[Path]]:
287
+ """The repository's root and the files of its commands, skills and agents."""
288
+ root = work_tree_for(cwd)
289
+ files: list[Path] = []
290
+ for folder in PROJECT_FOLDERS:
291
+ base = root / folder
292
+ if not base.is_dir():
293
+ continue
294
+ for current, dirs, names in os.walk(base):
295
+ dirs[:] = sorted(d for d in dirs if d not in SKIP_DIRS)
296
+ files += [Path(current) / n for n in sorted(names)]
297
+ if len(files) >= MAX_FILES:
298
+ return root, files[:MAX_FILES]
299
+ return root, files
300
+
301
+
302
+ def fingerprint(root: Path, files: list[Path]) -> str:
303
+ digest = hashlib.sha256()
304
+ for f in files:
305
+ digest.update(f.relative_to(root).as_posix().encode() + b"\0")
306
+ try:
307
+ digest.update(hashlib.sha256(f.read_bytes()).digest())
308
+ except OSError:
309
+ digest.update(b"?")
310
+ return digest.hexdigest()
311
+
312
+
313
+ def _approved() -> dict:
314
+ try:
315
+ data = json.loads(approvals_path().read_text())
316
+ except (OSError, ValueError):
317
+ return {}
318
+ return data if isinstance(data, dict) else {}
319
+
320
+
321
+ def status(cwd: Path) -> str:
322
+ """none (the repository brings nothing), approved, new or changed."""
323
+ root, files = project_files(cwd)
324
+ if not files:
325
+ return "none"
326
+ seen = _approved().get(str(root))
327
+ if seen is None:
328
+ return "new"
329
+ return "approved" if seen == fingerprint(root, files) else "changed"
330
+
331
+
332
+ def approve(cwd: Path) -> None:
333
+ root, files = project_files(cwd)
334
+ data = _approved()
335
+ data[str(root)] = fingerprint(root, files)
336
+ approvals_path().parent.mkdir(parents=True, exist_ok=True)
337
+ approvals_path().write_text(json.dumps(data, indent=2))
338
+
339
+
340
+ def describe(cwd: Path) -> list[str]:
341
+ """What the repository brings, one line per kind."""
342
+ from lcode.subagents import parse_agent
343
+
344
+ root = work_tree_for(cwd)
345
+ commands, _ = load_commands(cwd, include_project=True)
346
+ skills, _ = load_skills(cwd, include_project=True)
347
+ agents = []
348
+ for f in sorted((root / ".lcode" / "agents").glob("*.md")):
349
+ try:
350
+ agents.append(parse_agent(f.read_text(errors="replace"), f.stem, str(f)).name)
351
+ except (ValueError, OSError):
352
+ continue
353
+ lines = []
354
+ project_commands = [f"/{c.name}" for c in commands.values() if c.scope == "project"]
355
+ project_skills = [s.name for s in skills.values() if s.scope == "project"]
356
+ if project_commands:
357
+ lines.append(f"commands: {', '.join(project_commands)}")
358
+ if project_skills:
359
+ lines.append(f"skills: {', '.join(project_skills)}")
360
+ if agents:
361
+ lines.append(f"agents: {', '.join(agents)}")
362
+ return lines
363
+
364
+
365
+ def trust_project(cwd: Path, console: Console, interactive: bool) -> bool:
366
+ """Whether to use the repository's commands, skills and agents; asks the first time."""
367
+ state = status(cwd)
368
+ if state in ("none", "approved"):
369
+ return state == "approved"
370
+ root = work_tree_for(cwd)
371
+ if not interactive:
372
+ console.print(
373
+ f"[dim]Not using the commands, skills and agents in {escape(str(root))} until you approve them in a session.[/]"
374
+ )
375
+ return False
376
+ changed = " [yellow](changed since you approved them)[/]" if state == "changed" else ""
377
+ console.print(f"This repository brings its own commands, skills or agents{changed}:")
378
+ for line in describe(cwd) or ["(files in " + ", ".join(PROJECT_FOLDERS) + ")"]:
379
+ console.print(f" {escape(line)}")
380
+ console.print(
381
+ "[dim]They can steer the model and include scripts it may run (commands still ask first, unless "
382
+ "you allow them).[/]"
383
+ )
384
+ try:
385
+ answer = input(" Use them? [y/N] ").strip().lower()
386
+ except EOFError:
387
+ answer = ""
388
+ if answer in ("y", "yes"):
389
+ approve(cwd)
390
+ return True
391
+ console.print("[dim]Not using them in this session. You'll be asked again next time.[/]")
392
+ return False
393
+
394
+
395
+ @dataclass
396
+ class Extensions:
397
+ commands: dict[str, Command]
398
+ skills: dict[str, Skill]
399
+ problems: list[str]
400
+
401
+
402
+ def load(cwd: Path, include_project: bool, skills_from: str = "all") -> Extensions:
403
+ commands, command_problems = load_commands(cwd, include_project)
404
+ skills, skill_problems = load_skills(cwd, include_project, skills_from)
405
+ return Extensions(commands, skills, command_problems + skill_problems)
lcode/frontmatter.py ADDED
@@ -0,0 +1,62 @@
1
+ """The `---` header of markdown files: memory notes, agents, commands and skills.
2
+
3
+ A small, lenient subset of YAML, enough for these headers without a YAML dependency:
4
+ `key: value` lines, quoted values, block scalars (`key: |` and `key: >`, with indented lines below),
5
+ and nested maps or lists (kept as raw text). Skills written for other agents sometimes have values
6
+ that strict YAML rejects, such as an unquoted colon in a description; those are read as plain text.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import re
12
+
13
+ BLOCK = re.compile(r"^[|>][+-]?$")
14
+
15
+
16
+ def split(text: str) -> tuple[dict[str, str], str] | None:
17
+ """The header fields and the body after them, or None when the text has no header."""
18
+ m = re.match(r"?---[ \t]*\r?\n(.*?)\r?\n---[ \t]*(?:\r?\n|$)(.*)", text, re.S)
19
+ if not m:
20
+ return None
21
+ return parse(m.group(1)), m.group(2)
22
+
23
+
24
+ def parse(header: str) -> dict[str, str]:
25
+ fields: dict[str, str] = {}
26
+ lines = header.splitlines()
27
+ i = 0
28
+ while i < len(lines):
29
+ line = lines[i]
30
+ i += 1
31
+ if not line.strip() or line.lstrip().startswith("#") or line[:1].isspace():
32
+ continue
33
+ key, sep, value = line.partition(":")
34
+ if not sep:
35
+ continue
36
+ key, value = key.strip(), value.strip()
37
+ nested = []
38
+ while i < len(lines) and (not lines[i].strip() or lines[i][:1].isspace()):
39
+ nested.append(lines[i])
40
+ i += 1
41
+ while nested and not nested[-1].strip():
42
+ nested.pop()
43
+ if BLOCK.match(value):
44
+ indent = min((len(n) - len(n.lstrip()) for n in nested if n.strip()), default=0)
45
+ body = [n[indent:] for n in nested]
46
+ if value.startswith(">"):
47
+ value = re.sub(r"(?<!\n)\n(?!\n)", " ", "\n".join(body)).strip()
48
+ else:
49
+ value = "\n".join(body).rstrip()
50
+ elif not value and nested:
51
+ value = "\n".join(nested) # a nested map or list, kept as text
52
+ elif nested:
53
+ value = " ".join([value, *(n.strip() for n in nested)]).strip() # a plain value over several lines
54
+ fields[key] = unquote(value)
55
+ return fields
56
+
57
+
58
+ def unquote(value: str) -> str:
59
+ if len(value) >= 2 and value[0] == value[-1] and value[0] in "'\"":
60
+ inner = value[1:-1]
61
+ return inner.replace("''", "'") if value[0] == "'" else inner.replace('\\"', '"').replace("\\n", "\n")
62
+ return value
lcode/memory.py CHANGED
@@ -39,6 +39,7 @@ from rich.panel import Panel
39
39
  from rich.text import Text
40
40
 
41
41
  from lcode import config
42
+ from lcode.frontmatter import split as frontmatter
42
43
 
43
44
  if TYPE_CHECKING:
44
45
  from lcode.agent import Agent
@@ -186,19 +187,6 @@ def project_dir(cwd: Path) -> Path:
186
187
  return memory_dir() / "projects" / f"{slugify(name, 40) or 'folder'}-{key}"
187
188
 
188
189
 
189
- def frontmatter(text: str) -> tuple[dict[str, str], str] | None:
190
- """The `key: value` header between --- lines, and the body after it (None without a header)."""
191
- m = re.match(r"---\r?\n(.*?)\r?\n---\r?\n?(.*)", text, re.S)
192
- if not m:
193
- return None
194
- fields = {}
195
- for line in m.group(1).splitlines():
196
- key, sep, value = line.partition(":")
197
- if sep:
198
- fields[key.strip()] = value.strip()
199
- return fields, m.group(2)
200
-
201
-
202
190
  def parse(text: str, scope: str, path: Path | None = None) -> Note | None:
203
191
  split = frontmatter(text)
204
192
  if not split:
lcode/planning.py CHANGED
@@ -32,7 +32,7 @@ if TYPE_CHECKING:
32
32
  PLAN_MODE_TOOLS = frozenset(
33
33
  {
34
34
  "read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write",
35
- "agent", "memory", "present_plan", "mcp_find_tools",
35
+ "agent", "memory", "present_plan", "mcp_find_tools", "skill",
36
36
  }
37
37
  ) # fmt: skip
38
38
 
lcode/repl.py CHANGED
@@ -25,7 +25,7 @@ from rich.rule import Rule
25
25
  from rich.table import Table
26
26
  from rich.text import Text
27
27
 
28
- from lcode import __version__, catalog, limits, sessions, web
28
+ from lcode import __version__, catalog, extensions, limits, sessions, web
29
29
  from lcode import memory as memory_notes
30
30
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
31
31
  from lcode.catalog import MIN_USEFUL_CONTEXT
@@ -73,7 +73,7 @@ class InputCompleter(Completer):
73
73
  def get_completions(self, document, complete_event):
74
74
  text = document.text_before_cursor
75
75
  if text.startswith("/") and " " not in text:
76
- for cmd, desc in COMMANDS.items():
76
+ for cmd, desc in {**COMMANDS, **custom_commands(self.agent)}.items():
77
77
  if cmd.startswith(text):
78
78
  yield Completion(cmd, start_position=-len(text), display_meta=desc)
79
79
  return
@@ -382,6 +382,48 @@ def agents_command(agent: Agent, arg: str) -> None:
382
382
  )
383
383
 
384
384
 
385
+ def custom_commands(agent: Agent) -> dict[str, str]:
386
+ """The user's and the repository's commands and skills, as /name -> description (built-ins win)."""
387
+ ext = agent.extensions()
388
+ found = {f"/{name}": skill.description for name, skill in ext.skills.items()}
389
+ found.update({f"/{name}": command.description for name, command in ext.commands.items()})
390
+ return {name: description for name, description in found.items() if name not in COMMANDS}
391
+
392
+
393
+ def help_extensions(agent: Agent) -> None:
394
+ c, ext = agent.console, agent.extensions()
395
+ if ext.commands:
396
+ table = Table(title="Your commands", title_justify="left", header_style="bold")
397
+ for column in ("Command", "Does", "From"):
398
+ table.add_column(column)
399
+ for command in ext.commands.values():
400
+ hidden = " [yellow](hidden by the built-in command)[/]" if f"/{command.name}" in COMMANDS else ""
401
+ usage = f"/{command.name}" + (f" {command.argument_hint}" if command.argument_hint else "")
402
+ table.add_row(escape(usage), escape(command.description) + hidden, escape(agent.tools.rel(command.path)))
403
+ c.print(table)
404
+ if ext.skills:
405
+ table = Table(
406
+ title="Skills (the model loads them when a task matches; /name runs one)",
407
+ title_justify="left",
408
+ header_style="bold",
409
+ )
410
+ for column in ("Skill", "Does", "From"):
411
+ table.add_column(column)
412
+ for skill in ext.skills.values():
413
+ description = skill.description if len(skill.description) <= 140 else skill.description[:139] + "…"
414
+ if f"/{skill.name}" in COMMANDS or skill.name in ext.commands:
415
+ description += f" [yellow](/{skill.name} runs the command; the model can still load the skill)[/]"
416
+ table.add_row(f"/{escape(skill.name)}", escape(description), escape(agent.tools.rel(skill.folder)))
417
+ c.print(table)
418
+ for problem in ext.problems:
419
+ c.print(f"[yellow]Skipped {escape(problem)}[/]")
420
+ if not agent.settings.trust_project and extensions.status(agent.cwd) in ("new", "changed"):
421
+ c.print(
422
+ "[dim]This repository's own commands, skills and agents aren't in use: you didn't approve them "
423
+ "(restart lcode here to be asked again).[/]"
424
+ )
425
+
426
+
385
427
  def memory_status(agent: Agent) -> str:
386
428
  project, user = agent.memory().counts()
387
429
  if not (project or user):
@@ -716,6 +758,7 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
716
758
  "@path = attach a file · Shift+Tab = cycle permission mode"
717
759
  )
718
760
  c.print(Panel(f"{rows}\n\n [dim]{keys}[/]", title="lcode commands", border_style="cyan"))
761
+ help_extensions(agent)
719
762
  elif cmd == "/clear":
720
763
  memory_notes.reflect(agent)
721
764
  agent.new_session()
@@ -807,6 +850,23 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
807
850
  agent.tools.show_todos()
808
851
  elif cmd == "/init":
809
852
  run_safely(agent, INIT_PROMPT)
853
+ elif cmd[1:] in agent.extensions().commands:
854
+ command = agent.extensions().commands[cmd[1:]]
855
+ c.print(f"[dim]/{escape(command.name)} from {escape(agent.tools.rel(command.path))}[/]")
856
+ c.print(Rule(style="dim"))
857
+ previous = agent.allowed_tools
858
+ if command.tools is not None:
859
+ agent.allowed_tools = set(command.tools)
860
+ try:
861
+ run_safely(agent, command.render(arg))
862
+ finally:
863
+ agent.allowed_tools = previous
864
+ elif cmd[1:] in agent.extensions().skills:
865
+ skill = agent.extensions().skills[cmd[1:]]
866
+ agent.skills_loaded.add(skill.name)
867
+ c.print(f"[dim]Using the {escape(skill.name)} skill from {escape(agent.tools.rel(skill.folder))}[/]")
868
+ c.print(Rule(style="dim"))
869
+ run_safely(agent, f"{arg or f'Use the {skill.name} skill.'}\n\n{extensions.content(skill)}")
810
870
  else:
811
871
  c.print(f"[red]Unknown command {cmd}.[/] Type /help.")
812
872
  return True
lcode/subagents.py CHANGED
@@ -45,7 +45,7 @@ from rich.text import Text
45
45
 
46
46
  from lcode import config
47
47
  from lcode.checkpoints import _clean_env, work_tree_for
48
- from lcode.memory import frontmatter
48
+ from lcode.frontmatter import split as frontmatter
49
49
  from lcode.ollama import OllamaError
50
50
 
51
51
  if TYPE_CHECKING:
@@ -53,7 +53,7 @@ if TYPE_CHECKING:
53
53
  from lcode.permissions import Permissions
54
54
 
55
55
  READ_ONLY_TOOLS = frozenset(
56
- {"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write"}
56
+ {"read_file", "list_dir", "glob", "grep", "bash", "web_search", "web_fetch", "view_image", "todo_write", "skill"}
57
57
  )
58
58
  EDIT_TOOLS = frozenset({"write_file", "edit_file"})
59
59
  NOT_FOR_SUBAGENTS = frozenset({"agent", "memory"}) # no agents inside agents; only the main session remembers
@@ -123,9 +123,9 @@ BUILT_IN = {
123
123
  # ----------------------------------------------------------------------------- agent types
124
124
 
125
125
 
126
- def agent_dirs(cwd: Path) -> list[Path]:
127
- """Where custom agents live, lowest priority first: the user's, then the repository's."""
128
- return [config.CONFIG_DIR / "agents", work_tree_for(cwd) / ".lcode" / "agents"]
126
+ def agent_dirs(cwd: Path, include_project: bool = True) -> list[Path]:
127
+ """Where custom agents live, lowest priority first: the user's, then the repository's (once approved)."""
128
+ return [config.CONFIG_DIR / "agents", *([work_tree_for(cwd) / ".lcode" / "agents"] if include_project else [])]
129
129
 
130
130
 
131
131
  def parse_agent(text: str, name: str, source: str) -> AgentType:
@@ -173,11 +173,11 @@ def parse_agent(text: str, name: str, source: str) -> AgentType:
173
173
  )
174
174
 
175
175
 
176
- def load_types(cwd: Path) -> tuple[dict[str, AgentType], list[str]]:
176
+ def load_types(cwd: Path, include_project: bool = True) -> tuple[dict[str, AgentType], list[str]]:
177
177
  """The agent types for `cwd` (custom ones override built-in ones), and problems with custom ones."""
178
178
  types = dict(BUILT_IN)
179
179
  problems = []
180
- for folder in agent_dirs(cwd):
180
+ for folder in agent_dirs(cwd, include_project):
181
181
  if not folder.is_dir():
182
182
  continue
183
183
  for f in sorted(folder.glob("*.md")):
lcode/tools.py CHANGED
@@ -215,7 +215,7 @@ PRESENT_PLAN_SCHEMA = _fn(
215
215
  TOOL_NAMES = {
216
216
  s["function"]["name"]
217
217
  for s in [*SCHEMAS, WEB_SEARCH_SCHEMA, WEB_FETCH_SCHEMA, VIEW_IMAGE_SCHEMA, MEMORY_SCHEMA, PRESENT_PLAN_SCHEMA]
218
- }
218
+ } | {"agent", "skill"} # those two have schemas that depend on the session
219
219
 
220
220
 
221
221
  # ----------------------------------------------------------------------------- helpers
@@ -334,10 +334,15 @@ class Toolbox:
334
334
  p = Path(os.path.expanduser(path or "."))
335
335
  return (p if p.is_absolute() else self.agent.cwd / p).resolve()
336
336
 
337
- def path(self, path: str | None) -> Path:
338
- """Resolve a path from the model; with the sandbox on, it must be inside the project."""
337
+ def path(self, path: str | None, read: bool = False) -> Path:
338
+ """Resolve a path from the model; with the sandbox on, it must be inside the project (or, for
339
+ reading, inside a skill's folder)."""
339
340
  p = self.resolve(path)
340
341
  root = self.agent.sandbox_root()
342
+ if read and root is not None:
343
+ skills = self.agent.extensions().skills.values()
344
+ if any(p == s.folder or s.folder in p.parents for s in skills):
345
+ return p
341
346
  if root is not None and p != root and root not in p.parents:
342
347
  raise ToolError(f"{path} is outside the project ({root}); with the sandbox on, that's all you can use")
343
348
  return p
@@ -406,6 +411,15 @@ class Toolbox:
406
411
 
407
412
  return present(self.agent, title, plan)
408
413
 
414
+ # -- skills
415
+ def t_skill(self, name: str) -> str:
416
+ from lcode.extensions import activate
417
+
418
+ result = activate(self.agent, name)
419
+ if not result.startswith("Error:"):
420
+ self.console.print(Text(f" ⎿ loaded the {name} skill", style="dim"))
421
+ return result
422
+
409
423
  # -- memory
410
424
  def t_memory(
411
425
  self, action: str, name: str = "", type: str = "", description: str = "", details: str = "", scope: str = ""
@@ -459,7 +473,7 @@ class Toolbox:
459
473
 
460
474
  # -- read-only
461
475
  def t_read_file(self, path: str, offset: int = 1, limit: int = 2000) -> str:
462
- p = self.path(path)
476
+ p = self.path(path, read=True)
463
477
  if not p.exists():
464
478
  raise ToolError(f"{path} does not exist")
465
479
  if p.is_dir():
@@ -481,7 +495,7 @@ class Toolbox:
481
495
  return truncate(out, 120_000)
482
496
 
483
497
  def t_list_dir(self, path: str = ".", depth: int = 2) -> str:
484
- p = self.path(path)
498
+ p = self.path(path, read=True)
485
499
  if not p.is_dir():
486
500
  raise ToolError(f"{path} is not a directory")
487
501
  return tree(p, depth=max(1, min(int(depth or 2), 6)))
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.9.0
3
+ Version: 0.10.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/
@@ -80,6 +80,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
80
80
  your approval. `auto-edit` and `yolo` modes when you want speed.
81
81
  - **Sandbox (optional).** `lcode --sandbox` runs the model's shell commands in a container (Docker or
82
82
  Podman) that only sees your project, without network access unless you allow it.
83
+ - **Commands and skills.** Your own `/commands` as prompt templates, and skills in the
84
+ [Agent Skills](https://agentskills.io) format that the model loads only when a task needs them.
83
85
  - **Plan mode.** `/plan <request>` or Shift+Tab: the model explores read-only and presents a plan;
84
86
  you approve, edit or send it back before anything changes.
85
87
  - **Undo.** lcode saves a checkpoint before the model changes files; `/undo` takes back the last
@@ -197,7 +199,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
197
199
 
198
200
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
199
201
 
200
- - [Custom commands and skills](https://github.com/nasser1941/lcode/issues/42)
201
202
  - [Ready-made MCP for Metabase, Encord and Valohai](https://github.com/nasser1941/lcode/issues/53)
202
203
  - Then: [smarter context for small windows](https://github.com/nasser1941/lcode/issues/46), [language servers](https://github.com/nasser1941/lcode/issues/44), [a repository map with local embeddings](https://github.com/nasser1941/lcode/issues/45), [hooks](https://github.com/nasser1941/lcode/issues/43)
203
204
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
@@ -1,24 +1,26 @@
1
- lcode/__init__.py,sha256=nFv9AKdtRRoIhmSP_gIiY5ftUlCFh_s-FQeBjbqj6fI,117
1
+ lcode/__init__.py,sha256=Vt4u1brdsy5UMBysZVsHXtLIwN6S7GISlXj5GAGQI1w,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=fP52AvWe7WfTXmzbM9BH-_rIUtdEf2Ix1iBspeqcTr4,39130
3
+ lcode/agent.py,sha256=5vSYZYFg-5doSF1yx3YRWI8hKSMCxPnSNzJiIwicY_8,40626
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=Bh9wdAe5fZPzfKUdsbebpMObxqKxLZIDsI3OldhddO8,29610
8
- lcode/config.py,sha256=Wle_Gwv-FWQmD9jBt8jiHNtmdn3O5bOgkrZ6tbD7_9o,7947
7
+ lcode/cli.py,sha256=2xSELCKgYyqvJUAwG6P6qimZq6wmZGxwYAxCYfeZ7rk,30509
8
+ lcode/config.py,sha256=HwOAz-9LFkH9hOlQwPPG26oIGtOfd0b2Wq2I09WxIAE,8297
9
+ lcode/extensions.py,sha256=1MkCmfvQvcJt1XaqIEOYUruCTsrfQVI5TXOTWBkkJtI,15882
10
+ lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
9
11
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
10
12
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
11
- lcode/memory.py,sha256=SWB_xt_fdL2hX1mcUqK0drU7laZmg1CqIs0IIAehnCo,22361
13
+ lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
12
14
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
13
15
  lcode/ollama.py,sha256=0f2rkrm3_5Xcnuyvaxcfx5SeYVyRvHjiROXryHKmRUw,7085
14
16
  lcode/permissions.py,sha256=m8YNtCladVAmXB20ca8AwyfEOzHKS557B0SOvx1oHeM,4528
15
- lcode/planning.py,sha256=C7_JyArIywPygU9DdfaoN2EfgA6JGNnKafuIilkOzGw,6067
17
+ lcode/planning.py,sha256=Z30K2tbqoy2XimKwth1lYYz7OVaQUXGdDvBgops7pGE,6076
16
18
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
17
- lcode/repl.py,sha256=K04EpyIxyrP6I3oPh9tp8xqoIubplqHRXvWbqFdOKks,40356
19
+ lcode/repl.py,sha256=FnyYpRuvpqmIVWQbMSvagrFBvFcLjIAedFeF32u5Vp4,43612
18
20
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
19
21
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
20
- lcode/subagents.py,sha256=L5U-USz2fnGOx3Z28_sOA6btWmMKSGXVbFKQYcrZSDM,28147
21
- lcode/tools.py,sha256=VNhNkC43x2l20kqv06cbDXmdJHugDncFX0AIUfAr3CE,34820
22
+ lcode/subagents.py,sha256=ILGFwdFWfwKqExl2D5h_AArqlOris2N0aqh3Ccj895Q,28295
23
+ lcode/tools.py,sha256=Af2pbx8KIR4uLaWk2opbzMY8ZFQ22mGcu8FnRbX9DsM,35486
22
24
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
23
25
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
24
26
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -32,8 +34,8 @@ lcode/mcp/http.py,sha256=sG9MajwU4_6sqvZhwD4WfyzRKzaR_Lc8C02jwePz18E,7380
32
34
  lcode/mcp/manager.py,sha256=79nNWoT5pxgoQD_Pv4ZH0obHIm7rQ-LyB8OmJnQgc6k,15104
33
35
  lcode/mcp/protocol.py,sha256=-xwhA2Tz-vzR2zdSdbPx4IjTu29uRrXMaePg0vEz_Vc,5223
34
36
  lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
35
- lcode_cli-0.9.0.dist-info/METADATA,sha256=DW7hETwyh_0QCwMacZY-ofQbFPMPZTgChvrxjXDTb-8,11379
36
- lcode_cli-0.9.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
37
- lcode_cli-0.9.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
38
- lcode_cli-0.9.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
39
- lcode_cli-0.9.0.dist-info/RECORD,,
37
+ lcode_cli-0.10.0.dist-info/METADATA,sha256=qlFYNWlZjDePi7kqZ2JnRUVasJODV0Emqu6SDFhKvhI,11487
38
+ lcode_cli-0.10.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
39
+ lcode_cli-0.10.0.dist-info/entry_points.txt,sha256=pZ_uDQ98UzVAHUQeXn5HeyQWKGSH3r3T5aPHuSB89eI,41
40
+ lcode_cli-0.10.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
41
+ lcode_cli-0.10.0.dist-info/RECORD,,