lcode-cli 0.18.0__py3-none-any.whl → 0.19.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
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.18.0"
6
+ __version__ = "0.19.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/acp.py ADDED
@@ -0,0 +1,635 @@
1
+ """`lcode acp`: lcode as the agent inside an editor, over the Agent Client Protocol (ACP, version 1).
2
+
3
+ The editor starts `lcode acp` and talks JSON-RPC 2.0 over its stdin and stdout, one message per
4
+ line. The editor shows what happens:
5
+ - the model's answers and thinking stream as message chunks;
6
+ - each tool call is a tool call with its kind, files and, for edits, a diff;
7
+ - permission requests and plan approvals use the editor's own dialog;
8
+ - the todo list becomes the editor's plan;
9
+ - lcode's permission modes are the session's modes.
10
+
11
+ Files are read from and written through the editor's buffers when it supports that, so the model
12
+ sees unsaved changes and the editor tracks the edits. Everything else (tools, checkpoints, MCP
13
+ servers, memory) works as in the terminal.
14
+
15
+ Nothing but protocol messages may reach stdout: lcode's own output is discarded (or written to
16
+ the file in LCODE_ACP_LOG), and stdin is the protocol channel only.
17
+ """
18
+
19
+ from __future__ import annotations
20
+
21
+ import base64
22
+ import hashlib
23
+ import json
24
+ import os
25
+ import sys
26
+ import threading
27
+ from pathlib import Path
28
+ from typing import IO, TYPE_CHECKING, Any
29
+ from urllib.parse import unquote, urlparse
30
+
31
+ from rich.console import Console
32
+
33
+ from lcode import __version__
34
+
35
+ if TYPE_CHECKING:
36
+ from lcode.agent import Agent
37
+
38
+ PROTOCOL_VERSION = 1
39
+ MODES = [
40
+ ("ask", "Ask", "Ask before changing files or running commands"),
41
+ ("auto-edit", "Auto-edit", "Change files without asking; ask before commands"),
42
+ ("plan", "Plan", "Look around and plan; change nothing until you approve the plan"),
43
+ ("yolo", "Yolo", "Never ask"),
44
+ ]
45
+ KINDS = {
46
+ "read_file": "read", "view_image": "read", "list_dir": "search", "glob": "search", "grep": "search",
47
+ "repo_map": "search", "search_code": "search", "lsp": "search", "edit_file": "edit", "write_file": "edit",
48
+ "bash": "execute", "web_search": "fetch", "web_fetch": "fetch", "agent": "think", "todo_write": "think",
49
+ "present_plan": "switch_mode",
50
+ } # fmt: skip
51
+ STOP = {"success": "end_turn", "max_steps": "max_turn_requests", "interrupted": "cancelled"}
52
+ MAX_INLINE = 200_000 # bytes of a file the editor attaches that go into the prompt
53
+ METHOD_NOT_FOUND, INVALID_PARAMS, INTERNAL_ERROR, NOT_FOUND = -32601, -32602, -32603, -32002
54
+
55
+
56
+ class RpcError(Exception):
57
+ def __init__(self, code: int, message: str):
58
+ super().__init__(message)
59
+ self.code = code
60
+
61
+
62
+ # ----------------------------------------------------------------------------- JSON-RPC over stdio
63
+
64
+
65
+ class Connection:
66
+ """Newline-delimited JSON-RPC 2.0, both ways: the editor's requests and lcode's own."""
67
+
68
+ def __init__(self, inp: IO[bytes], out: IO[bytes]):
69
+ self.inp, self.out = inp, out
70
+ self.lock = threading.Lock()
71
+ self.pending: dict[int, tuple[threading.Event, dict]] = {}
72
+ self.next_id = 0
73
+ self.closed = False
74
+
75
+ def send(self, message: dict) -> None:
76
+ data = (json.dumps({"jsonrpc": "2.0", **message}, ensure_ascii=False) + "\n").encode()
77
+ with self.lock:
78
+ self.out.write(data)
79
+ self.out.flush()
80
+
81
+ def notify(self, method: str, params: dict) -> None:
82
+ self.send({"method": method, "params": params})
83
+
84
+ def request(self, method: str, params: dict) -> dict:
85
+ """Ask the editor and wait for its answer."""
86
+ with self.lock:
87
+ self.next_id += 1
88
+ request_id = self.next_id
89
+ done, slot = threading.Event(), {}
90
+ self.pending[request_id] = (done, slot)
91
+ self.send({"id": request_id, "method": method, "params": params})
92
+ done.wait()
93
+ if "error" in slot:
94
+ error = slot["error"] or {}
95
+ raise RpcError(int(error.get("code", INTERNAL_ERROR)), str(error.get("message", "the editor failed")))
96
+ return slot.get("result") or {}
97
+
98
+ def serve(self, handle) -> None:
99
+ """Read messages until the editor closes stdin; requests are handled in threads of their own."""
100
+ for raw in self.inp:
101
+ line = raw.strip()
102
+ if not line:
103
+ continue
104
+ try:
105
+ message = json.loads(line)
106
+ except ValueError:
107
+ self.send({"id": None, "error": {"code": -32700, "message": "parse error"}})
108
+ continue
109
+ if "method" in message:
110
+ threading.Thread(target=self._dispatch, args=(handle, message), daemon=True).start()
111
+ elif message.get("id") in self.pending:
112
+ done, slot = self.pending.pop(message["id"])
113
+ slot.update({k: message[k] for k in ("result", "error") if k in message})
114
+ done.set()
115
+ self.closed = True
116
+ for done, slot in list(self.pending.values()): # nobody will answer now
117
+ slot["error"] = {"code": INTERNAL_ERROR, "message": "the editor closed the connection"}
118
+ done.set()
119
+
120
+ def _dispatch(self, handle, message: dict) -> None:
121
+ request_id = message.get("id")
122
+ try:
123
+ result = handle(message["method"], message.get("params") or {})
124
+ if request_id is not None:
125
+ self.send({"id": request_id, "result": result if result is not None else {}})
126
+ except RpcError as e:
127
+ if request_id is not None:
128
+ self.send({"id": request_id, "error": {"code": e.code, "message": str(e)}})
129
+ except Exception as e: # never leave the editor waiting
130
+ if request_id is not None:
131
+ self.send({"id": request_id, "error": {"code": INTERNAL_ERROR, "message": f"{type(e).__name__}: {e}"}})
132
+
133
+
134
+ # ----------------------------------------------------------------------------- one editor session
135
+
136
+
137
+ def file_path(uri: str) -> Path | None:
138
+ parsed = urlparse(uri)
139
+ if parsed.scheme in ("", "file"):
140
+ return Path(unquote(parsed.path)) if parsed.path else None
141
+ return None
142
+
143
+
144
+ def call_title(agent: Agent, name: str, args: dict) -> str:
145
+ path = args.get("path")
146
+ rel = agent.tools.rel(agent.tools.resolve(path)) if isinstance(path, str) and path else ""
147
+ titles = {
148
+ "read_file": f"Read {rel}",
149
+ "list_dir": f"List {rel or '.'}",
150
+ "glob": f"Find {args.get('pattern', '')}",
151
+ "grep": f"Search for {args.get('pattern', '')}",
152
+ "edit_file": f"Edit {rel}",
153
+ "write_file": f"Write {rel}",
154
+ "bash": str(args.get("command", "")).strip()[:200],
155
+ "web_search": f"Search the web for {args.get('query', '')}",
156
+ "web_fetch": f"Fetch {args.get('url', '')}",
157
+ "todo_write": "Update the plan",
158
+ "present_plan": f"Plan: {args.get('title', '')}",
159
+ "agent": f"Subagent: {args.get('description') or args.get('type', '')}",
160
+ }
161
+ return titles.get(name) or agent.describe_call(name, args)
162
+
163
+
164
+ class Session:
165
+ """An lcode agent wired to the editor: its output becomes session/update notifications."""
166
+
167
+ def __init__(self, server: Server, agent: Agent):
168
+ self.server, self.agent, self.id = server, agent, agent.session_id
169
+ self.conn = server.conn
170
+ self.before: dict[str, str | None] = {} # file contents before an edit, by tool call id
171
+ self.mode = agent.perms.mode
172
+ agent.cancel = threading.Event()
173
+ agent.on_delta = self.delta
174
+ agent.on_event = self.event
175
+ agent.on_tool = self.started
176
+ agent.perms.approve = self.permission
177
+ agent.plan_review = self.review_plan
178
+ caps = server.client_caps.get("fs") or {}
179
+ if caps.get("readTextFile"):
180
+ agent.tools.reader = self.read_buffer
181
+ if caps.get("writeTextFile"):
182
+ agent.tools.writer = self.write_buffer
183
+
184
+ def update(self, update: dict) -> None:
185
+ self.conn.notify("session/update", {"sessionId": self.id, "update": update})
186
+
187
+ # -- the model's output
188
+ def delta(self, kind: str, text: str) -> None:
189
+ chunk = "agent_message_chunk" if kind == "text" else "agent_thought_chunk"
190
+ self.update({"sessionUpdate": chunk, "content": {"type": "text", "text": text}})
191
+
192
+ def call(self, call_id: str, name: str, args: dict, status: str) -> dict:
193
+ update: dict[str, Any] = {
194
+ "toolCallId": call_id,
195
+ "title": call_title(self.agent, name, args),
196
+ "kind": KINDS.get(name, "other"),
197
+ "status": status,
198
+ "rawInput": args,
199
+ }
200
+ path = args.get("path")
201
+ if isinstance(path, str) and path:
202
+ location: dict[str, Any] = {"path": str(self.agent.tools.resolve(path))}
203
+ if isinstance(args.get("offset"), int):
204
+ location["line"] = args["offset"]
205
+ update["locations"] = [location]
206
+ diff = self.proposed_diff(call_id, name, args)
207
+ if diff:
208
+ update["content"] = [diff]
209
+ return update
210
+
211
+ def proposed_diff(self, call_id: str, name: str, args: dict) -> dict | None:
212
+ """What an edit will change, as the editor's diff: the whole file before and after."""
213
+ if name not in ("edit_file", "write_file") or not isinstance(args.get("path"), str):
214
+ return None
215
+ path = self.agent.tools.resolve(args["path"])
216
+ try:
217
+ old = self.agent.tools.read_text(path) if path.is_file() else None
218
+ except OSError:
219
+ old = None
220
+ if name == "write_file":
221
+ new = str(args.get("content", ""))
222
+ else:
223
+ find, replace = str(args.get("old_string", "")), str(args.get("new_string", ""))
224
+ if old is None or not find or find not in old:
225
+ return {"type": "diff", "path": str(path), "oldText": find, "newText": replace}
226
+ new = old.replace(find, replace) if args.get("replace_all") else old.replace(find, replace, 1)
227
+ self.before[call_id] = old
228
+ return {"type": "diff", "path": str(path), "oldText": old, "newText": new}
229
+
230
+ def event(self, event: dict) -> None:
231
+ if event["type"] == "assistant":
232
+ for call in event["tool_calls"]:
233
+ self.update(
234
+ {"sessionUpdate": "tool_call", **self.call(call["id"], call["name"], call["arguments"], "pending")}
235
+ )
236
+ elif event["type"] == "tool_result":
237
+ update: dict[str, Any] = {
238
+ "sessionUpdate": "tool_call_update",
239
+ "toolCallId": event["id"],
240
+ "status": "failed" if event["error"] else "completed",
241
+ "rawOutput": {"output": event["output"]},
242
+ }
243
+ content = [{"type": "content", "content": {"type": "text", "text": event["output"]}}]
244
+ if event["name"] in ("edit_file", "write_file") and not event["error"]:
245
+ path = next((c for c in self.pending_paths(event["id"])), None)
246
+ if path is not None:
247
+ try:
248
+ after = path.read_text(errors="replace")
249
+ content = [{"type": "diff", "path": str(path), "oldText": self.before.pop(event["id"], None),
250
+ "newText": after}] # fmt: skip
251
+ except OSError:
252
+ pass
253
+ update["content"] = content
254
+ self.update(update)
255
+ if event["name"] == "todo_write":
256
+ self.send_plan()
257
+ if self.agent.perms.mode != self.mode: # an approved plan switched the mode
258
+ self.mode = self.agent.perms.mode
259
+ self.update({"sessionUpdate": "current_mode_update", "currentModeId": self.mode})
260
+
261
+ def pending_paths(self, call_id: str):
262
+ for message in reversed(self.agent.messages):
263
+ for call in message.get("tool_calls") or []:
264
+ if call.get("id") == call_id:
265
+ args = call.get("function", {}).get("arguments") or {}
266
+ if isinstance(args, dict) and isinstance(args.get("path"), str):
267
+ yield self.agent.tools.resolve(args["path"])
268
+ return
269
+
270
+ def started(self, name: str, args: dict) -> None:
271
+ self.update(
272
+ {"sessionUpdate": "tool_call_update", "toolCallId": self.agent.current_call, "status": "in_progress"}
273
+ )
274
+
275
+ def send_plan(self) -> None:
276
+ entries = [
277
+ {"content": str(t.get("content", "")), "priority": "medium",
278
+ "status": t.get("status") if t.get("status") in ("pending", "in_progress", "completed") else "pending"}
279
+ for t in self.agent.tools.todos
280
+ ] # fmt: skip
281
+ self.update({"sessionUpdate": "plan", "entries": entries})
282
+
283
+ # -- asking the user, in the editor
284
+ def ask(self, tool_call: dict, options: list[dict]) -> str | None:
285
+ """The option the user picked, or None if the request was cancelled."""
286
+ try:
287
+ reply = self.conn.request(
288
+ "session/request_permission", {"sessionId": self.id, "toolCall": tool_call, "options": options}
289
+ )
290
+ except RpcError:
291
+ return None
292
+ outcome = reply.get("outcome") or {}
293
+ return outcome.get("optionId") if outcome.get("outcome") == "selected" else None
294
+
295
+ def permission(self, request: dict) -> bool | str:
296
+ call_id = self.agent.current_call
297
+ name = next(
298
+ (c.get("function", {}).get("name", "") for m in reversed(self.agent.messages)
299
+ for c in m.get("tool_calls") or [] if c.get("id") == call_id), "",
300
+ ) # fmt: skip
301
+ args = next(
302
+ (c.get("function", {}).get("arguments") or {} for m in reversed(self.agent.messages)
303
+ for c in m.get("tool_calls") or [] if c.get("id") == call_id), {},
304
+ ) # fmt: skip
305
+ tool_call = self.call(call_id, name, args if isinstance(args, dict) else {}, "pending") if name else {
306
+ "toolCallId": call_id or "permission", "title": request["title"], "kind": "other", "status": "pending"
307
+ } # fmt: skip
308
+ tool_call["title"] = request["title"]
309
+ scope = "file edits" if request["kind"] == "edit" else request["key"].split(":", 1)[-1]
310
+ choice = self.ask(
311
+ tool_call,
312
+ [
313
+ {"optionId": "allow", "name": "Allow", "kind": "allow_once"},
314
+ {"optionId": "always", "name": f"Always allow {scope} in this session", "kind": "allow_always"},
315
+ {"optionId": "reject", "name": "Reject", "kind": "reject_once"},
316
+ ],
317
+ )
318
+ if choice == "always":
319
+ self.agent.perms.always.add("edit" if request["kind"] == "edit" else request["key"])
320
+ if choice in ("allow", "always"):
321
+ return True
322
+ if choice is None and self.agent.cancel is not None and self.agent.cancel.is_set():
323
+ return "The user denied this: they cancelled the request."
324
+ return "The user denied this in the editor. Ask how to proceed, or choose a different approach."
325
+
326
+ def review_plan(self, title: str, plan: str) -> str:
327
+ tool_call = {
328
+ "toolCallId": self.agent.current_call or "plan",
329
+ "title": f"Plan: {title}",
330
+ "kind": "switch_mode",
331
+ "status": "pending",
332
+ "content": [{"type": "content", "content": {"type": "text", "text": plan}}],
333
+ }
334
+ choice = self.ask(
335
+ tool_call,
336
+ [
337
+ {"optionId": "ask", "name": "Approve, and ask before changes", "kind": "allow_once"},
338
+ {"optionId": "auto-edit", "name": "Approve, and change files without asking", "kind": "allow_always"},
339
+ {"optionId": "keep", "name": "Keep planning", "kind": "reject_once"},
340
+ ],
341
+ )
342
+ if choice in ("ask", "auto-edit"):
343
+ return choice
344
+ return "The user wants to keep planning. Ask what they'd like to change in the plan; don't change anything yet."
345
+
346
+ # -- the editor's buffers
347
+ def read_buffer(self, path: Path) -> str | None:
348
+ try:
349
+ return self.conn.request("fs/read_text_file", {"sessionId": self.id, "path": str(path)}).get("content")
350
+ except RpcError:
351
+ return None # not open in the editor, or not readable there: lcode reads the file itself
352
+
353
+ def write_buffer(self, path: Path, content: str) -> None:
354
+ try:
355
+ self.conn.request("fs/write_text_file", {"sessionId": self.id, "path": str(path), "content": content})
356
+ except RpcError:
357
+ pass # lcode writes the file itself
358
+
359
+ # -- requests
360
+ def prompt(self, blocks: list[dict]) -> dict:
361
+ from lcode import api
362
+
363
+ text = self.render(blocks)
364
+ if not text.strip():
365
+ return {"stopReason": "end_turn"}
366
+ command = self.command(text)
367
+ if command == "":
368
+ return {"stopReason": "end_turn"}
369
+ if command is not None:
370
+ text = command
371
+ self.agent.cancel.clear()
372
+ result = api.run_request(self.agent, text)
373
+ self.update({"sessionUpdate": "usage_update", "used": self.agent.ctx_used, "size": self.agent.settings.context})
374
+ self.update({"sessionUpdate": "session_info_update", "title": self.agent.session_title or None})
375
+ if result.status == "error":
376
+ raise RpcError(INTERNAL_ERROR, result.error)
377
+ return {"stopReason": STOP.get(result.status, "end_turn")}
378
+
379
+ def command(self, text: str) -> str | None:
380
+ """A /command from the editor: the user's own commands, and /review."""
381
+ if not text.startswith("/"):
382
+ return None
383
+ name, _, arg = text[1:].partition(" ")
384
+ commands = self.agent.extensions().commands
385
+ if name in commands:
386
+ return commands[name].render(arg.strip())
387
+ if name == "review":
388
+ from lcode import gitflow
389
+
390
+ try:
391
+ prompt = gitflow.review_prompt(self.agent, arg.strip())
392
+ except gitflow.GitError as e:
393
+ self.delta("text", f"Nothing to review: {e}")
394
+ return ""
395
+ self.agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
396
+ return prompt
397
+ return None
398
+
399
+ def render(self, blocks: list[dict]) -> str:
400
+ """The editor's prompt (text, attached files, images) as one request for lcode."""
401
+ parts = []
402
+ for block in blocks:
403
+ kind = block.get("type")
404
+ if kind == "text":
405
+ parts.append(str(block.get("text", "")))
406
+ elif kind == "resource_link":
407
+ parts.append(self.attached(block.get("uri", ""), None))
408
+ elif kind == "resource":
409
+ resource = block.get("resource") or {}
410
+ parts.append(self.attached(resource.get("uri", ""), resource.get("text")))
411
+ elif kind == "image" and block.get("data"):
412
+ parts.append(self.image(block["data"], block.get("mimeType", "image/png")))
413
+ return "\n\n".join(p for p in parts if p)
414
+
415
+ def attached(self, uri: str, text: str | None) -> str:
416
+ path = file_path(uri)
417
+ where = self.agent.tools.rel(path) if path else uri
418
+ if text is None and path is not None and path.is_file() and path.stat().st_size <= MAX_INLINE:
419
+ text = self.agent.tools.read_text(path)
420
+ if text is None:
421
+ return f"[The user points at {where}]"
422
+ return f"[The user attached {where}]\n```\n{text[:MAX_INLINE].rstrip(chr(10))}\n```"
423
+
424
+ def image(self, data: str, mime: str) -> str:
425
+ from lcode import config
426
+
427
+ raw = base64.b64decode(data)
428
+ folder = config.STATE_DIR / "acp-images"
429
+ folder.mkdir(parents=True, exist_ok=True)
430
+ path = folder / f"{hashlib.sha256(raw).hexdigest()[:16]}.{mime.split('/')[-1].split('+')[0] or 'png'}"
431
+ path.write_bytes(raw)
432
+ return f"@{path}"
433
+
434
+ def replay(self) -> None:
435
+ """A loaded session's conversation, sent to the editor as it happened."""
436
+ results: dict[str, str] = {}
437
+ names: dict[str, str] = {}
438
+ for message in self.agent.messages[1:]:
439
+ role, content = message.get("role"), message.get("content") or ""
440
+ if role == "user" and content:
441
+ self.update({"sessionUpdate": "user_message_chunk", "content": {"type": "text", "text": content}})
442
+ elif role == "assistant":
443
+ if content:
444
+ self.update({"sessionUpdate": "agent_message_chunk", "content": {"type": "text", "text": content}})
445
+ for i, call in enumerate(message.get("tool_calls") or []):
446
+ call.setdefault("id", f"call_loaded_{len(names)}_{i}")
447
+ fn = call.get("function", {})
448
+ args = fn.get("arguments") if isinstance(fn.get("arguments"), dict) else {}
449
+ names[call["id"]] = fn.get("name", "")
450
+ update = {"sessionUpdate": "tool_call", "toolCallId": call["id"],
451
+ "title": call_title(self.agent, fn.get("name", ""), args),
452
+ "kind": KINDS.get(fn.get("name", ""), "other"), "status": "completed", "rawInput": args} # fmt: skip
453
+ self.update(update)
454
+ elif role == "tool":
455
+ call_id = message.get("tool_call_id") or next((i for i in reversed(names) if i not in results), "")
456
+ if call_id:
457
+ results[call_id] = content
458
+ self.update({"sessionUpdate": "tool_call_update", "toolCallId": call_id, "status": "completed",
459
+ "content": [{"type": "content", "content": {"type": "text", "text": content[:4000]}}]}) # fmt: skip
460
+
461
+
462
+ # ----------------------------------------------------------------------------- the agent side
463
+
464
+
465
+ class Server:
466
+ def __init__(self, conn: Connection, console: Console):
467
+ self.conn, self.console = conn, console
468
+ self.client_caps: dict = {}
469
+ self.sessions: dict[str, Session] = {}
470
+
471
+ def handle(self, method: str, params: dict) -> dict | None:
472
+ handlers = {
473
+ "initialize": self.initialize,
474
+ "authenticate": lambda p: {},
475
+ "session/new": self.new_session,
476
+ "session/load": self.load_session,
477
+ "session/prompt": self.prompt,
478
+ "session/cancel": self.cancel,
479
+ "session/set_mode": self.set_mode,
480
+ }
481
+ if method not in handlers:
482
+ raise RpcError(METHOD_NOT_FOUND, f"lcode doesn't support {method}")
483
+ return handlers[method](params)
484
+
485
+ def initialize(self, params: dict) -> dict:
486
+ self.client_caps = params.get("clientCapabilities") or {}
487
+ return {
488
+ "protocolVersion": PROTOCOL_VERSION,
489
+ "agentCapabilities": {
490
+ "loadSession": True,
491
+ "promptCapabilities": {"image": True, "audio": False, "embeddedContext": True},
492
+ "mcpCapabilities": {"http": True, "sse": False},
493
+ },
494
+ "agentInfo": {"name": "lcode", "title": "lcode", "version": __version__},
495
+ "authMethods": [],
496
+ }
497
+
498
+ def open(self, params: dict) -> Session:
499
+ from lcode import api
500
+
501
+ cwd = Path(str(params.get("cwd") or ""))
502
+ if not cwd.is_absolute():
503
+ raise RpcError(INVALID_PARAMS, "cwd must be an absolute path")
504
+ try:
505
+ agent = api.open_agent(cwd, api.Options(interactive=False), self.console)
506
+ except api.SetupError as e:
507
+ raise RpcError(INTERNAL_ERROR, str(e)) from e
508
+ agent.mcp = self.mcp(cwd, params.get("mcpServers") or [], agent)
509
+ session = Session(self, agent)
510
+ self.sessions[session.id] = session
511
+ return session
512
+
513
+ def mcp(self, cwd: Path, editor_servers: list[dict], agent: Agent):
514
+ """lcode's MCP servers, plus the ones the editor passes."""
515
+ from lcode import config
516
+ from lcode.mcp import config as mcp_config
517
+ from lcode.mcp.manager import McpManager
518
+
519
+ project = mcp_config.project_file(cwd)
520
+ servers, problems = mcp_config.load_all(cwd, include_project=bool(project and mcp_config.is_approved(project)))
521
+ names = {s.name for s in servers}
522
+ for entry in editor_servers:
523
+ name = str(entry.get("name") or "editor")
524
+ if name in names:
525
+ continue
526
+ if entry.get("url"):
527
+ raw = {
528
+ "type": "http",
529
+ "url": entry["url"],
530
+ "headers": {h["name"]: h["value"] for h in entry.get("headers") or []},
531
+ }
532
+ else:
533
+ raw = {"command": entry.get("command", ""), "args": entry.get("args") or [],
534
+ "env": {e["name"]: e["value"] for e in entry.get("env") or []}} # fmt: skip
535
+ server = mcp_config.parse(name, raw, source="editor")
536
+ if server.error:
537
+ problems.append(f"{name}: {server.error}")
538
+ else:
539
+ servers.append(server)
540
+ for problem in problems:
541
+ self.console.print(f"MCP: {problem}")
542
+ if not servers:
543
+ return None
544
+ manager = McpManager(cwd, servers, config.load()["mcp_tools"])
545
+ manager.start()
546
+ return manager
547
+
548
+ def modes(self, session: Session) -> dict:
549
+ return {
550
+ "currentModeId": session.agent.perms.mode,
551
+ "availableModes": [{"id": i, "name": n, "description": d} for i, n, d in MODES],
552
+ }
553
+
554
+ def new_session(self, params: dict) -> dict:
555
+ session = self.open(params)
556
+ self.commands(session)
557
+ return {"sessionId": session.id, "modes": self.modes(session)}
558
+
559
+ def load_session(self, params: dict) -> dict:
560
+ from lcode import sessions
561
+
562
+ session = self.open(params)
563
+ wanted = str(params.get("sessionId", ""))
564
+ found = next((s for s in sessions.list_sessions(session.agent.cwd, limit=1000) if s.id == wanted), None)
565
+ if found is None:
566
+ self.sessions.pop(session.id, None)
567
+ raise RpcError(NOT_FOUND, f"no saved lcode session {wanted} in {session.agent.cwd}")
568
+ self.sessions.pop(session.id, None)
569
+ session.agent.load(found)
570
+ session.id = session.agent.session_id
571
+ self.sessions[session.id] = session
572
+ session.replay()
573
+ self.commands(session)
574
+ return {"modes": self.modes(session)}
575
+
576
+ def commands(self, session: Session) -> None:
577
+ available = [{"name": "review", "description": "Review the uncommitted changes, or the branch against a base",
578
+ "input": {"hint": "base branch (optional)"}}] # fmt: skip
579
+ for name, command in session.agent.extensions().commands.items():
580
+ if name != "review":
581
+ entry: dict = {"name": name, "description": command.description}
582
+ if command.argument_hint:
583
+ entry["input"] = {"hint": command.argument_hint}
584
+ available.append(entry)
585
+ session.update({"sessionUpdate": "available_commands_update", "availableCommands": available})
586
+
587
+ def session(self, params: dict) -> Session:
588
+ session = self.sessions.get(str(params.get("sessionId", "")))
589
+ if session is None:
590
+ raise RpcError(NOT_FOUND, f"no session {params.get('sessionId')}")
591
+ return session
592
+
593
+ def prompt(self, params: dict) -> dict:
594
+ session = self.session(params)
595
+ try:
596
+ return session.prompt(params.get("prompt") or [])
597
+ finally:
598
+ session.agent.no_changes = ""
599
+
600
+ def cancel(self, params: dict) -> None:
601
+ session = self.sessions.get(str(params.get("sessionId", "")))
602
+ if session is not None and session.agent.cancel is not None:
603
+ session.agent.cancel.set()
604
+ from lcode import subagents
605
+
606
+ if session.agent.response is not None:
607
+ subagents.abort(session.agent.response) # stop the model's answer now
608
+ return None
609
+
610
+ def set_mode(self, params: dict) -> dict:
611
+ session = self.session(params)
612
+ mode = str(params.get("modeId", ""))
613
+ if mode not in {m[0] for m in MODES}:
614
+ raise RpcError(INVALID_PARAMS, f"unknown mode {mode}")
615
+ session.agent.perms.mode = session.mode = mode
616
+ return {}
617
+
618
+
619
+ def main() -> int:
620
+ """Serve one editor over stdin and stdout until it disconnects."""
621
+ inp, out = sys.stdin.buffer, sys.stdout.buffer
622
+ sys.stdout = sys.stderr # a stray print must not corrupt the protocol
623
+ # Open for the life of the process:
624
+ sys.stdin = open(os.devnull) # noqa: SIM115 # nothing may wait for typed input: stdin is the protocol
625
+ log = os.environ.get("LCODE_ACP_LOG")
626
+ output = open(log, "a") if log else open(os.devnull, "w") # noqa: SIM115 # lcode's own output
627
+ console = Console(file=output, width=120, force_terminal=False)
628
+ conn = Connection(inp, out)
629
+ server = Server(conn, console)
630
+ conn.serve(server.handle)
631
+ for session in server.sessions.values():
632
+ from lcode import api
633
+
634
+ api.close_agent(session.agent)
635
+ return 0
lcode/agent.py CHANGED
@@ -257,6 +257,10 @@ class Agent:
257
257
  self.cancel: threading.Event | None = None # set from another thread to stop
258
258
  self.on_tool = None # called with (name, arguments) before each tool runs
259
259
  self.on_event: Callable[[dict], None] | None = None # each step and tool result, for --output stream-json
260
+ self.on_delta: Callable[[str, str], None] | None = None # ("text" or "thinking", piece) as it streams
261
+ self.current_call = "" # id of the tool call that's running, so a permission request can name it
262
+ # Decides on a presented plan instead of asking in the terminal: "ask", "auto-edit", or why not.
263
+ self.plan_review: Callable[[str, str], str] | None = None
260
264
  self.max_steps = MAX_STEPS_PER_TURN
261
265
  self.turn_status = "" # how the last request ended: success, max_steps
262
266
  self.response = None # the streaming response, so another thread can abort it
@@ -649,6 +653,8 @@ class Agent:
649
653
  msg = chunk.get("message", {})
650
654
  if msg.get("thinking"):
651
655
  thinking += msg["thinking"]
656
+ if self.on_delta:
657
+ self.on_delta("thinking", msg["thinking"])
652
658
  progress.update("Thinking", msg["thinking"])
653
659
  if self.settings.show_thinking:
654
660
  stop_spinner() # raw text is printed as it streams
@@ -659,6 +665,8 @@ class Agent:
659
665
  self.console.print("\n")
660
666
  printed_thinking = False
661
667
  content += msg["content"]
668
+ if self.on_delta:
669
+ self.on_delta("text", msg["content"])
662
670
  progress.update("Writing", msg["content"])
663
671
  # Finished markdown blocks print above the spinner, which keeps running meanwhile.
664
672
  start_spinner()
@@ -667,6 +675,8 @@ class Agent:
667
675
  tool_calls.extend(msg["tool_calls"])
668
676
  if chunk.get("done"):
669
677
  final = chunk
678
+ if self.cancel is not None and self.cancel.is_set():
679
+ raise KeyboardInterrupt # cancelled while the answer streamed: its end never came
670
680
  except KeyboardInterrupt:
671
681
  stop_spinner()
672
682
  md.flush()
@@ -833,6 +843,7 @@ class Agent:
833
843
  raise
834
844
  for i, call in enumerate(calls):
835
845
  name, args = call.get("function", {}).get("name", ""), call_arguments(call)
846
+ self.current_call = call.get("id", "")
836
847
  if self.on_tool:
837
848
  self.on_tool(name, args)
838
849
  if name not in ("bash", "todo_write", "web_search", "web_fetch", "agent"): # those print their own
lcode/cli.py CHANGED
@@ -774,6 +774,11 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
774
774
  from lcode.mcp.commands import build_parser as mcp_parser
775
775
 
776
776
  subs["mcp"] = mcp_parser()
777
+ subs["acp"] = argparse.ArgumentParser(
778
+ prog="lcode acp",
779
+ description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout "
780
+ "(Zed and other ACP editors start it for you; see the docs).",
781
+ )
777
782
  subs["action"] = argparse.ArgumentParser(
778
783
  prog="lcode action",
779
784
  description="Run as a GitHub Action on a self-hosted runner: answer @lcode in issues and pull requests, "
@@ -782,6 +787,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
782
787
  return subs
783
788
 
784
789
 
790
+ def cmd_acp(args) -> None:
791
+ from lcode import acp
792
+
793
+ sys.exit(acp.main())
794
+
795
+
785
796
  def cmd_action(args) -> None:
786
797
  from lcode import action
787
798
 
@@ -851,6 +862,7 @@ COMMANDS = {
851
862
  "mcp": cmd_mcp,
852
863
  "index": cmd_index,
853
864
  "action": cmd_action,
865
+ "acp": cmd_acp,
854
866
  }
855
867
 
856
868
 
lcode/permissions.py CHANGED
@@ -76,7 +76,8 @@ class Permissions:
76
76
  self.always: set[str] = set()
77
77
  self.rules = Rules() # allow and deny lists from the settings (lcode.hooks)
78
78
  self.on_prompt = None # called with the title before lcode asks the user (notification hooks)
79
- self.approve: Callable[[dict], bool] | None = None # decides instead of asking (see lcode.api)
79
+ # Decides instead of asking (lcode.api, lcode acp): True, False, or a message saying why not.
80
+ self.approve: Callable[[dict], bool | str] | None = None
80
81
 
81
82
  def rule(self, kind: str, target: str) -> tuple[bool, str] | None:
82
83
  """A rule's verdict on an action: (allowed, message for the model), or None when no rule matches."""
@@ -109,9 +110,10 @@ class Permissions:
109
110
  target = target or key.split(":", 1)[-1]
110
111
  if kind == "bash":
111
112
  target = LEADING_CD.sub("", target) # the command itself, without `cd <folder> &&`
112
- if self.approve({"kind": kind, "title": title, "target": target}):
113
+ verdict = self.approve({"kind": kind, "title": title, "target": target, "key": key})
114
+ if verdict is True:
113
115
  return True, ""
114
- return False, NOT_ASKED
116
+ return False, verdict if isinstance(verdict, str) and verdict else NOT_ASKED
115
117
  if self.on_prompt:
116
118
  self.on_prompt(title)
117
119
  self.console.print(Panel(body, title=title, title_align="left", border_style="yellow"))
lcode/planning.py CHANGED
@@ -92,6 +92,11 @@ def present(agent: Agent, title: str, plan: str) -> str:
92
92
  plan = plan.strip()
93
93
  if not plan:
94
94
  return "Error: the plan is empty. Write the steps, the files, the risks and how you'll verify the work."
95
+ if agent.plan_review is not None: # an editor shows the plan (lcode acp)
96
+ choice = agent.plan_review(title, plan)
97
+ if choice in ("ask", "auto-edit"):
98
+ return approve(agent, title, plan, choice, False)
99
+ return choice or "The user didn't approve the plan. Ask them what they'd like to change."
95
100
  edited = False
96
101
  while True:
97
102
  c.print(Panel(Markdown(plan), title=f"Plan: {escape(title)}", title_align="left", border_style="cyan"))
lcode/tools.py CHANGED
@@ -15,6 +15,7 @@ import signal
15
15
  import subprocess
16
16
  import threading
17
17
  import time
18
+ from collections.abc import Callable
18
19
  from pathlib import Path
19
20
  from typing import TYPE_CHECKING
20
21
 
@@ -366,6 +367,9 @@ class Toolbox:
366
367
  self.agent = agent
367
368
  self.read_mtimes: dict[str, float] = {}
368
369
  self.todos: list[dict] = []
370
+ # An editor's buffers (lcode acp): read unsaved changes and write through the editor.
371
+ self.reader: Callable[[Path], str | None] | None = None
372
+ self.writer: Callable[[Path, str], None] | None = None
369
373
 
370
374
  @property
371
375
  def console(self):
@@ -611,7 +615,7 @@ class Toolbox:
611
615
  raise ToolError(f"{path} is an image; look at it with view_image")
612
616
  if is_binary(p):
613
617
  raise ToolError(f"{path} is a binary file ({p.stat().st_size} bytes)")
614
- lines = p.read_text(errors="replace").splitlines()
618
+ lines = self.read_text(p).splitlines()
615
619
  offset, limit = max(1, int(offset or 1)), max(1, int(limit or 2000))
616
620
  chunk = lines[offset - 1 : offset - 1 + limit]
617
621
  self.read_mtimes[str(p)] = p.stat().st_mtime
@@ -704,9 +708,20 @@ class Toolbox:
704
708
  diff = diff[:12_000] + "\n... (diff truncated for display)\n"
705
709
  return Syntax(diff or "(no changes)", "diff", theme="monokai", word_wrap=True)
706
710
 
711
+ def read_text(self, p: Path) -> str:
712
+ """A file's text: the editor's buffer when lcode runs inside one, else the file on disk."""
713
+ if self.reader is not None:
714
+ text = self.reader(p)
715
+ if text is not None:
716
+ return text
717
+ return p.read_text(errors="replace")
718
+
707
719
  def _write(self, p: Path, content: str) -> None:
708
720
  p.parent.mkdir(parents=True, exist_ok=True)
709
- p.write_text(content)
721
+ if self.writer is not None:
722
+ self.writer(p, content) # the editor updates its buffer (and the file)
723
+ if not p.exists() or p.read_text(errors="replace") != content:
724
+ p.write_text(content) # the file on disk must match, for checkpoints and the tools
710
725
  self.read_mtimes[str(p)] = p.stat().st_mtime
711
726
 
712
727
  def t_write_file(self, path: str, content: str) -> str:
@@ -716,7 +731,7 @@ class Toolbox:
716
731
  if p.is_dir():
717
732
  raise ToolError(f"{path} is a directory")
718
733
  self._check_fresh(p)
719
- body = self._diff(p, p.read_text(errors="replace"), content)
734
+ body = self._diff(p, self.read_text(p), content)
720
735
  else:
721
736
  lines = content.splitlines()
722
737
  preview = "\n".join(lines[:60]) + (f"\n... ({len(lines) - 60} more lines)" if len(lines) > 60 else "")
@@ -725,7 +740,7 @@ class Toolbox:
725
740
  ok, feedback = self.agent.perms.request("edit", "edit", f"{verb} {self.rel(p)}", body, self.project_rel(p))
726
741
  if not ok:
727
742
  return feedback
728
- before = p.read_text(errors="replace") if exists else None
743
+ before = self.read_text(p) if exists else None
729
744
  self.agent.checkpoint()
730
745
  self._write(p, content)
731
746
  n = len(content.splitlines())
@@ -741,7 +756,7 @@ class Toolbox:
741
756
  raise ToolError("old_string and new_string are identical")
742
757
  if not old_string:
743
758
  raise ToolError("old_string is empty; use write_file to create or overwrite files")
744
- text = p.read_text(errors="replace")
759
+ text = self.read_text(p)
745
760
  count = text.count(old_string)
746
761
  if count == 0:
747
762
  new_text = fuzzy_replace(text, old_string, new_string)
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.18.0
3
+ Version: 0.19.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/
@@ -104,6 +104,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
104
104
  - **Git workflow.** `/commit` writes messages in your repository's style, `/review` checks the
105
105
  changes with `file:line` findings, `/pr` opens the pull request, each after you approve it, and
106
106
  `lcode --worktree` runs parallel sessions on one repository.
107
+ - **In your editor.** Zed, JetBrains IDEs, Neovim and other editors that speak the Agent Client
108
+ Protocol run lcode with `lcode acp`, with diffs and approvals in the editor.
107
109
  - **Automation.** `lcode -p --output json` for scripts and CI, a Python API (`from lcode import
108
110
  Session`), and a GitHub Action that answers `@lcode` in issues and pull requests on your own
109
111
  self-hosted runner.
@@ -214,7 +216,6 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
214
216
 
215
217
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
216
218
 
217
- - [Editor integration through the Agent Client Protocol](https://github.com/nasser1941/lcode/issues/49)
218
219
  - [Long-running work: finish notifications and background commands](https://github.com/nasser1941/lcode/issues/51)
219
220
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
220
221
 
@@ -1,14 +1,15 @@
1
- lcode/__init__.py,sha256=WtOJl9ZuDxmhgQBhqCaBR_KVw2PffYBBxhlQXcCzw7I,473
1
+ lcode/__init__.py,sha256=7yjbvkgWF_Vh6y72kETMa3SVkh4RP81rLJzvo11gIV8,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
+ lcode/acp.py,sha256=M_CdTVqRSmLn33QD56Le4238Vz1ZxffnHCfjmajSv34,28940
3
4
  lcode/action.py,sha256=1r3abjheYFYQ-EUArvotls8pLmE3YjNMGHAy_iF0Z70,16198
4
- lcode/agent.py,sha256=MmXL2CZJzGaxqM8fpA0fEnnDgiBqrYSuUIhAtVIYcYY,48852
5
+ lcode/agent.py,sha256=pB7rDZIbWiRrc8Ye4rA_LzHVqJ36yoTUN6nFsUeg428,49664
5
6
  lcode/api.py,sha256=kXlzXhGwkNdW9sWpxgOvL9h5pPzhzKRLXKrL1-eN3XY,15110
6
7
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
7
8
  lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
8
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
9
10
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
10
11
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
11
- lcode/cli.py,sha256=3p9NX6sVa8us7DDh5oFQh29aOdFIsOnFAa5DOL4OQec,39115
12
+ lcode/cli.py,sha256=zo2QTHrdxVaJwIjCEOsfR99byPqeCkKHE5p_Xh8kfb8,39471
12
13
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
13
14
  lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
14
15
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
@@ -22,15 +23,15 @@ lcode/lsp.py,sha256=NtSW7xapv7fg2HAQLuYK1nwIqT7nG0LK1hiTpOmla_4,26086
22
23
  lcode/memory.py,sha256=WZEHTp-lVxAwS-yyLQR2xB3TMZ9sejmX2Ivg7T-tl3w,21949
23
24
  lcode/models.toml,sha256=lt2G64zzo_Ryxv2ISRSNZoVD_JqCqYqsM01n8WoXGs8,4098
24
25
  lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
25
- lcode/permissions.py,sha256=Ce5_rE7VsOyFPtgzfDrIq1n19ofvtbf9g4-Jfh0TFSQ,6181
26
- lcode/planning.py,sha256=hZjVka7i9TswJrXLbjbZJ0PBc6zHpmO64JqMiaFYnMQ,6110
26
+ lcode/permissions.py,sha256=i04YLtqYCxwApgawomPp4d35uk9HAJtpKY__gfw8j9s,6347
27
+ lcode/planning.py,sha256=mGXMU-khHyGHE8IHxxrsQc-FFOzbdRa4MWHMU19UR7o,6439
27
28
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
28
29
  lcode/repl.py,sha256=zJ2hj1c-tx35Sms8V-1W4XsyfFZ9omu9bZgGpEgBYi8,47655
29
30
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
30
31
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
31
32
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
32
33
  lcode/subagents.py,sha256=S_ZBpqnKFxRKhe-lE-I8SHc3yMOhe0zW-SD2K-Gwn28,28832
33
- lcode/tools.py,sha256=zpFFY3dK8-g9So_MmUoj7qU8Stmx5b6PQrpcNba-s6E,42360
34
+ lcode/tools.py,sha256=Y4XmQsoLIA32S255DnCwOwebGPgQay1k7H_A9R5DINA,43145
34
35
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
35
36
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
36
37
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -48,8 +49,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
48
49
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
49
50
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
50
51
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
51
- lcode_cli-0.18.0.dist-info/METADATA,sha256=sgLqPzqqVoC9Q2253XGc2GPJK-Ktet1UKof6Sds0Vkw,12571
52
- lcode_cli-0.18.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
53
- lcode_cli-0.18.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
54
- lcode_cli-0.18.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
55
- lcode_cli-0.18.0.dist-info/RECORD,,
52
+ lcode_cli-0.19.0.dist-info/METADATA,sha256=cUJGqerCzNU65yzxUbn48STtIOP3fjvVbItArdo1vJ4,12642
53
+ lcode_cli-0.19.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
54
+ lcode_cli-0.19.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
55
+ lcode_cli-0.19.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
56
+ lcode_cli-0.19.0.dist-info/RECORD,,