lcode-cli 0.3.1__py3-none-any.whl → 0.4.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 +42 -3
- lcode/bench.py +27 -25
- lcode/cli.py +54 -9
- lcode/config.py +4 -0
- lcode/mcp/__init__.py +5 -0
- lcode/mcp/auth.py +392 -0
- lcode/mcp/catalog.py +109 -0
- lcode/mcp/catalog.toml +198 -0
- lcode/mcp/client.py +165 -0
- lcode/mcp/commands.py +403 -0
- lcode/mcp/config.py +221 -0
- lcode/mcp/http.py +161 -0
- lcode/mcp/manager.py +360 -0
- lcode/mcp/protocol.py +124 -0
- lcode/mcp/stdio.py +153 -0
- lcode/permissions.py +3 -1
- lcode/repl.py +52 -1
- lcode/tools.py +27 -0
- {lcode_cli-0.3.1.dist-info → lcode_cli-0.4.0.dist-info}/METADATA +5 -2
- lcode_cli-0.4.0.dist-info/RECORD +34 -0
- lcode_cli-0.3.1.dist-info/RECORD +0 -23
- {lcode_cli-0.3.1.dist-info → lcode_cli-0.4.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.3.1.dist-info → lcode_cli-0.4.0.dist-info}/entry_points.txt +0 -0
- {lcode_cli-0.3.1.dist-info → lcode_cli-0.4.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
lcode/agent.py
CHANGED
|
@@ -15,12 +15,14 @@ from pathlib import Path
|
|
|
15
15
|
|
|
16
16
|
from rich.console import Console
|
|
17
17
|
from rich.markdown import Markdown
|
|
18
|
+
from rich.markup import escape
|
|
18
19
|
from rich.panel import Panel
|
|
19
20
|
from rich.text import Text
|
|
20
21
|
|
|
21
22
|
from lcode import catalog, limits, sessions, web
|
|
22
23
|
from lcode.checkpoints import Checkpoints
|
|
23
24
|
from lcode.config import format_tokens
|
|
25
|
+
from lcode.mcp import McpManager
|
|
24
26
|
from lcode.ollama import Ollama, OllamaError
|
|
25
27
|
from lcode.permissions import Permissions
|
|
26
28
|
from lcode.render import MarkdownStreamer
|
|
@@ -150,6 +152,8 @@ class Agent:
|
|
|
150
152
|
self.tools = Toolbox(self)
|
|
151
153
|
self.session_id = self.new_session_id()
|
|
152
154
|
self.checkpoints = Checkpoints(self.console, settings.checkpoints)
|
|
155
|
+
self.mcp: McpManager | None = None # set by the CLI when MCP servers are configured
|
|
156
|
+
self._mcp_prompt = "" # the MCP part at the end of the system prompt
|
|
153
157
|
self.session_name = ""
|
|
154
158
|
self.session_title = ""
|
|
155
159
|
self.ctx_used = 0
|
|
@@ -188,9 +192,32 @@ class Agent:
|
|
|
188
192
|
return web.resolve_backend(self.settings.search_backend, self.settings.searxng_url)
|
|
189
193
|
|
|
190
194
|
def tool_schemas(self) -> list[dict]:
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
195
|
+
schemas = list(SCHEMAS)
|
|
196
|
+
if self.settings.web != "off":
|
|
197
|
+
schemas += [*([WEB_SEARCH_SCHEMA] if self.search_backend() else []), WEB_FETCH_SCHEMA]
|
|
198
|
+
if self.mcp:
|
|
199
|
+
schemas += self.mcp.schemas(self.settings.context)
|
|
200
|
+
return schemas
|
|
201
|
+
|
|
202
|
+
def prepare_mcp(self) -> None:
|
|
203
|
+
"""Before a request: wait for MCP servers still starting and describe them in the system prompt."""
|
|
204
|
+
if not self.mcp:
|
|
205
|
+
return
|
|
206
|
+
if self.mcp.pending:
|
|
207
|
+
with self.console.status(f"Starting MCP servers: {', '.join(self.mcp.pending)}", spinner="dots"):
|
|
208
|
+
self.mcp.wait()
|
|
209
|
+
for state in self.mcp.servers.values():
|
|
210
|
+
if state.status in ("failed", "login") and not state.reported:
|
|
211
|
+
state.reported = True
|
|
212
|
+
self.console.print(f"[yellow]MCP server {state.name}: {escape(state.error)}[/] [dim](/mcp)[/]")
|
|
213
|
+
self.mcp.refresh_changed()
|
|
214
|
+
section = self.mcp.prompt_section(self.settings.context)
|
|
215
|
+
if section != self._mcp_prompt:
|
|
216
|
+
content = self.messages[0]["content"]
|
|
217
|
+
if self._mcp_prompt and content.endswith(self._mcp_prompt):
|
|
218
|
+
content = content[: -len(self._mcp_prompt)]
|
|
219
|
+
self.messages[0]["content"] = content + section
|
|
220
|
+
self._mcp_prompt = section
|
|
194
221
|
|
|
195
222
|
def web_prompt(self) -> str:
|
|
196
223
|
if self.settings.web == "off":
|
|
@@ -203,6 +230,7 @@ class Agent:
|
|
|
203
230
|
|
|
204
231
|
def reset(self) -> None:
|
|
205
232
|
self.messages = [{"role": "system", "content": self.system_prompt()}]
|
|
233
|
+
self._mcp_prompt = ""
|
|
206
234
|
self.tools.read_mtimes.clear()
|
|
207
235
|
self.ctx_used = len(self.messages[0]["content"]) // 3
|
|
208
236
|
|
|
@@ -254,6 +282,7 @@ class Agent:
|
|
|
254
282
|
else:
|
|
255
283
|
note = f"The session's directory {saved_cwd} no longer exists; staying in {self.cwd}"
|
|
256
284
|
self.messages = [{"role": "system", "content": self.system_prompt()}, *data.get("messages", [])[1:]]
|
|
285
|
+
self._mcp_prompt = ""
|
|
257
286
|
self.session_id = info.id
|
|
258
287
|
self.checkpoints.load(info.id, data.get("checkpoints") or [])
|
|
259
288
|
self.session_name = data.get("name", "")
|
|
@@ -415,6 +444,15 @@ class Agent:
|
|
|
415
444
|
return f"glob({args.get('pattern', '')})"
|
|
416
445
|
if name == "list_dir":
|
|
417
446
|
return f"list_dir({args.get('path', '.')})"
|
|
447
|
+
if self.mcp and self.mcp.owns(name):
|
|
448
|
+
if name == "mcp_find_tools":
|
|
449
|
+
return f"mcp_find_tools({args.get('query', '')!r})"
|
|
450
|
+
try:
|
|
451
|
+
state, tool, arguments = self.mcp.resolve(name, args)
|
|
452
|
+
except Exception:
|
|
453
|
+
return name
|
|
454
|
+
preview = json.dumps(arguments, ensure_ascii=False)
|
|
455
|
+
return f"{state.name} › {tool['name']}({preview if len(preview) <= 80 else preview[:79] + '…'})"
|
|
418
456
|
return name
|
|
419
457
|
|
|
420
458
|
def run_turn(self, user_text: str) -> None:
|
|
@@ -429,6 +467,7 @@ class Agent:
|
|
|
429
467
|
self.checkpoints.before_change(self.cwd)
|
|
430
468
|
|
|
431
469
|
def _run_turn(self, user_text: str) -> None:
|
|
470
|
+
self.prepare_mcp()
|
|
432
471
|
self.messages.append({"role": "user", "content": self.expand_mentions(user_text)})
|
|
433
472
|
malformed = 0
|
|
434
473
|
for _ in range(MAX_STEPS_PER_TURN):
|
lcode/bench.py
CHANGED
|
@@ -850,31 +850,33 @@ def run_task(
|
|
|
850
850
|
ollama: Ollama, settings: Settings, task: Task, timeout: float, log: Console, keep: bool, usage: dict
|
|
851
851
|
) -> TaskResult:
|
|
852
852
|
folder = Path(tempfile.mkdtemp(prefix=f"lcode-bench-{task.id}-"))
|
|
853
|
-
for rel, content in task.files.items():
|
|
854
|
-
(folder / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
855
|
-
(folder / rel).write_text(content)
|
|
856
|
-
agent = Agent(ollama, settings, folder, console=log)
|
|
857
|
-
error = None
|
|
858
|
-
started = time.monotonic()
|
|
859
|
-
try:
|
|
860
|
-
with time_limit(timeout):
|
|
861
|
-
agent.run_turn(task.prompt)
|
|
862
|
-
except TaskTimeout:
|
|
863
|
-
error = f"timed out after {timeout:.0f}s"
|
|
864
|
-
except OllamaError as e:
|
|
865
|
-
error = str(e).splitlines()[0][:200]
|
|
866
|
-
seconds = time.monotonic() - started
|
|
867
|
-
for key, value in agent.usage.items():
|
|
868
|
-
usage[key] = usage.get(key, 0) + value
|
|
869
|
-
answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
|
|
870
853
|
try:
|
|
871
|
-
|
|
872
|
-
|
|
873
|
-
|
|
874
|
-
|
|
875
|
-
|
|
876
|
-
|
|
877
|
-
|
|
854
|
+
for rel, content in task.files.items():
|
|
855
|
+
(folder / rel).parent.mkdir(parents=True, exist_ok=True)
|
|
856
|
+
(folder / rel).write_text(content)
|
|
857
|
+
agent = Agent(ollama, settings, folder, console=log)
|
|
858
|
+
error = None
|
|
859
|
+
started = time.monotonic()
|
|
860
|
+
try:
|
|
861
|
+
with time_limit(timeout):
|
|
862
|
+
agent.run_turn(task.prompt)
|
|
863
|
+
except TaskTimeout:
|
|
864
|
+
error = f"timed out after {timeout:.0f}s"
|
|
865
|
+
except OllamaError as e:
|
|
866
|
+
error = str(e).splitlines()[0][:200]
|
|
867
|
+
seconds = time.monotonic() - started
|
|
868
|
+
for key, value in agent.usage.items():
|
|
869
|
+
usage[key] = usage.get(key, 0) + value
|
|
870
|
+
answer = next((m.get("content", "") for m in reversed(agent.messages) if m.get("role") == "assistant"), "")
|
|
871
|
+
try:
|
|
872
|
+
check = task.check(folder, answer)
|
|
873
|
+
except Exception as e: # a check must never crash the benchmark
|
|
874
|
+
check = Check(False, f"check failed: {type(e).__name__}: {e}")
|
|
875
|
+
finally: # also when Ctrl+C stops the task
|
|
876
|
+
if keep:
|
|
877
|
+
(folder / "lcode-bench.log").write_text(log.file.getvalue() if isinstance(log.file, io.StringIO) else "")
|
|
878
|
+
else:
|
|
879
|
+
shutil.rmtree(folder, ignore_errors=True)
|
|
878
880
|
return TaskResult(
|
|
879
881
|
id=task.id,
|
|
880
882
|
passed=check.passed and error is None,
|
|
@@ -1010,7 +1012,7 @@ def summary_rows(runs: list[ModelRun], tasks: list[Task]) -> list[tuple[str, lis
|
|
|
1010
1012
|
return "—" if run.error else value
|
|
1011
1013
|
|
|
1012
1014
|
rows += [
|
|
1013
|
-
("Passed", [cell(r, f"{r.passed}/{len(r.tasks)}") for r in runs]),
|
|
1015
|
+
("Passed", [cell(r, f"{r.passed}/{len(r.tasks)}" + (" (stopped)" if r.interrupted else "")) for r in runs]),
|
|
1014
1016
|
("Time", [cell(r, duration(r.seconds)) for r in runs]),
|
|
1015
1017
|
("Generation", [cell(r, f"{r.generation_tps:.1f} tok/s" if r.generation_tps else "?") for r in runs]),
|
|
1016
1018
|
("Prompt reading", [cell(r, f"{r.prompt_tps:,.0f} tok/s" if r.prompt_tps else "?") for r in runs]),
|
lcode/cli.py
CHANGED
|
@@ -310,6 +310,19 @@ def cmd_doctor(args) -> None:
|
|
|
310
310
|
bool(shutil.which("rg")) or None,
|
|
311
311
|
)
|
|
312
312
|
line("git", "found" if shutil.which("git") else "not found", bool(shutil.which("git")) or None)
|
|
313
|
+
from lcode.mcp import config as mcp_config
|
|
314
|
+
|
|
315
|
+
try:
|
|
316
|
+
servers = mcp_config.load_user()
|
|
317
|
+
names = ", ".join(n for n, raw in servers.items() if not (isinstance(raw, dict) and raw.get("disabled")))
|
|
318
|
+
line(
|
|
319
|
+
"MCP", f"{names} (check them with lcode mcp list)" if names else "no servers (see lcode mcp catalog)", True
|
|
320
|
+
)
|
|
321
|
+
except ValueError as e:
|
|
322
|
+
line("MCP", str(e), False)
|
|
323
|
+
ok = False
|
|
324
|
+
node = shutil.which("npx")
|
|
325
|
+
line("Node.js", "found" if node else "not found (some MCP servers need npx)", True if node else None)
|
|
313
326
|
sys.exit(0 if ok else 1)
|
|
314
327
|
|
|
315
328
|
|
|
@@ -367,7 +380,14 @@ def cmd_bench(args) -> None:
|
|
|
367
380
|
console.print(f"[bold]lcode bench[/] · {hw.describe()} · Ollama {version}")
|
|
368
381
|
console.print("[dim]Each task runs in a new temporary folder with every permission granted and web access off.[/]")
|
|
369
382
|
names = args.models or [cfg["model"]]
|
|
370
|
-
|
|
383
|
+
resolved: dict[str, tuple[str, ModelSpec | None] | NotInstalled] = {}
|
|
384
|
+
for name in names:
|
|
385
|
+
try:
|
|
386
|
+
resolved[name] = resolve_model(ollama, name)
|
|
387
|
+
except NotInstalled as e:
|
|
388
|
+
resolved[name] = e
|
|
389
|
+
benched = {r[0] for r in resolved.values() if isinstance(r, tuple)}
|
|
390
|
+
others = [m.get("name", "?") for m in ollama.running() if m.get("name", "").removesuffix(":latest") not in benched]
|
|
371
391
|
if others:
|
|
372
392
|
console.print(
|
|
373
393
|
f"[yellow]Already loaded in Ollama: {', '.join(others)}. Models that share the GPU run slower; "
|
|
@@ -375,12 +395,12 @@ def cmd_bench(args) -> None:
|
|
|
375
395
|
)
|
|
376
396
|
runs = []
|
|
377
397
|
for i, name in enumerate(names):
|
|
378
|
-
|
|
379
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
runs.append(bench.ModelRun(name, name, error=str(e)))
|
|
398
|
+
found = resolved[name]
|
|
399
|
+
if isinstance(found, NotInstalled):
|
|
400
|
+
console.print(f"\n[yellow]Skipping {name}: {found}[/]")
|
|
401
|
+
runs.append(bench.ModelRun(name, name, error=str(found)))
|
|
383
402
|
continue
|
|
403
|
+
model, spec = found
|
|
384
404
|
ctx, note = choose_context(ollama, model, spec, context, hw)
|
|
385
405
|
if note:
|
|
386
406
|
console.print(f"[yellow]{model}, context {format_tokens(ctx)}: {note}[/]")
|
|
@@ -467,14 +487,22 @@ def cmd_chat(args) -> None:
|
|
|
467
487
|
checkpoints=cfg["checkpoints"],
|
|
468
488
|
)
|
|
469
489
|
agent = Agent(ollama, settings, cwd, console=console)
|
|
470
|
-
|
|
490
|
+
if not args.no_mcp:
|
|
491
|
+
from lcode.mcp.commands import start_session
|
|
492
|
+
|
|
493
|
+
agent.mcp = start_session(cwd, cfg["mcp_tools"], console, interactive=not args.prompt)
|
|
494
|
+
try:
|
|
495
|
+
repl(agent, prompt=args.prompt, hardware=hw, cont=args.cont, resume=args.resume)
|
|
496
|
+
finally:
|
|
497
|
+
if agent.mcp:
|
|
498
|
+
agent.mcp.close()
|
|
471
499
|
|
|
472
500
|
|
|
473
501
|
def build_parser() -> argparse.ArgumentParser:
|
|
474
502
|
parser = argparse.ArgumentParser(
|
|
475
503
|
prog="lcode",
|
|
476
504
|
description="A local-first terminal coding agent powered by open-weight models via Ollama.",
|
|
477
|
-
epilog="Subcommands: lcode setup | models | doctor | config | bench (lcode <subcommand> --help). "
|
|
505
|
+
epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp (lcode <subcommand> --help). "
|
|
478
506
|
"Docs: https://nasser1941.github.io/lcode/",
|
|
479
507
|
)
|
|
480
508
|
parser.add_argument("-p", "--prompt", help="run one request non-interactively and exit")
|
|
@@ -493,6 +521,7 @@ def build_parser() -> argparse.ArgumentParser:
|
|
|
493
521
|
parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
|
|
494
522
|
parser.add_argument("--no-think", action="store_true", help="disable model reasoning (faster, less accurate)")
|
|
495
523
|
parser.add_argument("--no-web", action="store_true", help="don't let the model search or fetch web pages")
|
|
524
|
+
parser.add_argument("--no-mcp", action="store_true", help="don't start MCP servers in this session")
|
|
496
525
|
parser.add_argument("--show-thinking", action="store_true", help="print the model's reasoning as it streams")
|
|
497
526
|
parser.add_argument("-V", "--version", action="version", version=f"lcode {__version__}")
|
|
498
527
|
return parser
|
|
@@ -527,10 +556,26 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
|
|
|
527
556
|
p.add_argument("-v", "--verbose", action="store_true", help="show the model working, like a normal session")
|
|
528
557
|
p.add_argument("--list", action="store_true", help="list the tasks and exit")
|
|
529
558
|
subs["bench"] = p
|
|
559
|
+
from lcode.mcp.commands import build_parser as mcp_parser
|
|
560
|
+
|
|
561
|
+
subs["mcp"] = mcp_parser()
|
|
530
562
|
return subs
|
|
531
563
|
|
|
532
564
|
|
|
533
|
-
|
|
565
|
+
def cmd_mcp(args) -> None:
|
|
566
|
+
from lcode.mcp.commands import run
|
|
567
|
+
|
|
568
|
+
sys.exit(run(args, console))
|
|
569
|
+
|
|
570
|
+
|
|
571
|
+
COMMANDS = {
|
|
572
|
+
"setup": cmd_setup,
|
|
573
|
+
"models": cmd_models,
|
|
574
|
+
"doctor": cmd_doctor,
|
|
575
|
+
"config": cmd_config,
|
|
576
|
+
"bench": cmd_bench,
|
|
577
|
+
"mcp": cmd_mcp,
|
|
578
|
+
}
|
|
534
579
|
|
|
535
580
|
|
|
536
581
|
def main(argv: list[str] | None = None) -> None:
|
lcode/config.py
CHANGED
|
@@ -24,6 +24,7 @@ DEFAULT_MODEL = "qwen3.6-35b"
|
|
|
24
24
|
PERMISSION_MODES = ("ask", "auto-edit", "yolo")
|
|
25
25
|
WEB_MODES = ("on", "ask", "off")
|
|
26
26
|
SEARCH_BACKENDS = ("auto", "ollama", "brave", "tavily", "searxng")
|
|
27
|
+
MCP_TOOL_MODES = ("auto", "direct", "search")
|
|
27
28
|
|
|
28
29
|
# key -> (default, type, help)
|
|
29
30
|
SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
@@ -38,6 +39,7 @@ SETTINGS: dict[str, tuple[object, type, str]] = {
|
|
|
38
39
|
"search_backend": ("auto", str, "auto | ollama | brave | tavily | searxng (keys come from environment variables)"),
|
|
39
40
|
"searxng_url": (None, str, "your SearXNG instance, e.g. http://localhost:8888"),
|
|
40
41
|
"checkpoints": (True, bool, "snapshot files before the model changes them, so /undo can restore them"),
|
|
42
|
+
"mcp_tools": ("auto", str, "how MCP tools reach the model: auto | direct | search (on demand, saves context)"),
|
|
41
43
|
}
|
|
42
44
|
ENV_OVERRIDES = {
|
|
43
45
|
"LCODE_MODEL": "model",
|
|
@@ -97,6 +99,8 @@ def coerce(key: str, value: object) -> object:
|
|
|
97
99
|
if value not in WEB_MODES:
|
|
98
100
|
raise ConfigError(f"web must be one of {', '.join(WEB_MODES)}")
|
|
99
101
|
return value
|
|
102
|
+
if key == "mcp_tools" and value not in MCP_TOOL_MODES:
|
|
103
|
+
raise ConfigError(f"mcp_tools must be one of {', '.join(MCP_TOOL_MODES)}")
|
|
100
104
|
if key == "search_backend" and value not in SEARCH_BACKENDS:
|
|
101
105
|
raise ConfigError(f"search_backend must be one of {', '.join(SEARCH_BACKENDS)}")
|
|
102
106
|
if key == "permission_mode" and value not in PERMISSION_MODES:
|