lcode-cli 0.16.0__py3-none-any.whl → 0.17.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.16.0"
3
+ __version__ = "0.17.0"
lcode/agent.py CHANGED
@@ -130,7 +130,14 @@ def git_info(cwd: Path) -> str:
130
130
  return "not a git repository"
131
131
  changed = git("status", "--short").stdout.strip().splitlines()
132
132
  log = git("log", "--oneline", "-5").stdout.strip() or "(no commits)"
133
- return f"branch {branch.stdout.strip()}, {len(changed)} changed file(s)\nRecent commits:\n{log}"
133
+ dirs = git("rev-parse", "--path-format=absolute", "--git-dir", "--git-common-dir").stdout.split()
134
+ worktree = (
135
+ "\nThis folder is a git worktree, separate from the repository's main checkout: read, change and "
136
+ "commit files only here, with paths inside this folder."
137
+ if len(dirs) == 2 and dirs[0] != dirs[1]
138
+ else ""
139
+ )
140
+ return f"branch {branch.stdout.strip()}, {len(changed)} changed file(s){worktree}\nRecent commits:\n{log}"
134
141
  except (OSError, subprocess.TimeoutExpired):
135
142
  return "unknown"
136
143
 
@@ -242,6 +249,7 @@ class Agent:
242
249
  # Set on subagents (see lcode.subagents):
243
250
  self.allowed_tools: set[str] | None = None # None: every tool
244
251
  self.read_only = False # only read-only shell commands
252
+ self.no_changes = "" # set during a review: why nothing may change (read-only tools only)
245
253
  self.cancel: threading.Event | None = None # set from another thread to stop
246
254
  self.on_tool = None # called with (name, arguments) before each tool runs
247
255
  self.response = None # the streaming response, so another thread can abort it
@@ -931,4 +939,6 @@ class Agent:
931
939
  else:
932
940
  note = ""
933
941
  self.settings.context = tokens
942
+ if getattr(self.ollama, "kind", "ollama") != "ollama": # the server keeps its own window
943
+ return f"lcode now uses {format_tokens(tokens)} tokens of the context window{note}, and summarizes sooner."
934
944
  return f"Context window set to {format_tokens(tokens)} tokens{note}. The model reloads on the next request."
lcode/cli.py CHANGED
@@ -626,6 +626,17 @@ def cmd_chat(args) -> None:
626
626
  cwd = Path(args.repo).expanduser().resolve()
627
627
  if not cwd.is_dir():
628
628
  fail(f"not a directory: {cwd}")
629
+ worktree = None
630
+ if args.worktree is not None:
631
+ from lcode import gitflow
632
+
633
+ try:
634
+ worktree = gitflow.start_worktree(cwd, args.worktree)
635
+ except gitflow.GitError as e:
636
+ fail(f"--worktree: {e}")
637
+ cwd = worktree.cwd
638
+ state = "a new worktree" if worktree.created_branch else "the worktree"
639
+ console.print(f"[dim]Working in {state} {worktree.path}, on branch {escape(worktree.branch)}.[/]")
629
640
  ollama = connect(cfg)
630
641
  hw = detect()
631
642
  check_ollama(ollama, hw)
@@ -714,6 +725,8 @@ def cmd_chat(args) -> None:
714
725
  agent.mcp.close()
715
726
  if agent.sandbox:
716
727
  agent.sandbox.stop()
728
+ if worktree is not None:
729
+ console.print(f"[dim]{escape(worktree.finish())}[/]")
717
730
 
718
731
 
719
732
  def build_parser() -> argparse.ArgumentParser:
@@ -736,6 +749,14 @@ def build_parser() -> argparse.ArgumentParser:
736
749
  metavar="SESSION",
737
750
  help="resume a saved session: pick from a list, or give its number, name or id",
738
751
  )
752
+ parser.add_argument(
753
+ "-w",
754
+ "--worktree",
755
+ nargs="?",
756
+ const="",
757
+ metavar="NAME",
758
+ help="work in a git worktree of its own, on branch NAME (new, or continue one); removed if nothing changed",
759
+ )
739
760
  parser.add_argument("--plan", action="store_true", help="start in plan mode: agree on a plan before any change")
740
761
  parser.add_argument("--auto-edit", action="store_true", help="apply file edits without asking")
741
762
  parser.add_argument("--yolo", action="store_true", help="never ask for permission (edits and commands)")
lcode/gitflow.py ADDED
@@ -0,0 +1,640 @@
1
+ """Git workflow: /commit, /review, /pr, and sessions in a worktree of their own (lcode --worktree).
2
+
3
+ /commit and /pr ask the model for a message (or a title and description) in one extra request
4
+ alongside the conversation, so it knows why the changes were made and Ollama reuses the cached
5
+ prompt; that request isn't kept in the conversation. Nothing is committed, pushed or opened
6
+ without the user's yes, also in yolo mode. /review runs as a normal request in which the model can
7
+ read but not change anything.
8
+ """
9
+
10
+ from __future__ import annotations
11
+
12
+ import fnmatch
13
+ import hashlib
14
+ import json
15
+ import re
16
+ import secrets
17
+ import shutil
18
+ import subprocess
19
+ import tempfile
20
+ import time
21
+ from dataclasses import dataclass
22
+ from pathlib import Path
23
+ from typing import TYPE_CHECKING
24
+
25
+ from rich.markup import escape
26
+ from rich.panel import Panel
27
+
28
+ if TYPE_CHECKING:
29
+ from lcode.agent import Agent
30
+
31
+ # Files that often hold secrets: /commit leaves them out unless the user stages them.
32
+ SENSITIVE = (".env", ".env.*", "*.pem", "*.key", "*.p12", "*.pfx", "id_rsa*", "id_ed25519*", "id_ecdsa*",
33
+ "*credentials*", "*secret*", "*.keystore", "*.jks", ".netrc", ".pgpass") # fmt: skip
34
+ MAX_UNTRACKED_LINES = 200 # of a new file, in the diff the model sees
35
+ MIN_DIFF_CHARS = 6000
36
+ MAX_DIFF_CHARS = 120_000
37
+
38
+ COMMIT_SCHEMA = {
39
+ "type": "object",
40
+ "properties": {"files": {"type": "array", "items": {"type": "string"}}, "message": {"type": "string"}},
41
+ "required": ["files", "message"],
42
+ }
43
+ PR_SCHEMA = {
44
+ "type": "object",
45
+ "properties": {"branch": {"type": "string"}, "title": {"type": "string"}, "body": {"type": "string"}},
46
+ "required": ["branch", "title", "body"],
47
+ }
48
+
49
+ COMMIT_PROMPT = """Write the git commit message for the changes below.{hint}
50
+
51
+ The repository's recent commit messages; match their style (length, tense, capitals, prefixes such as "fix:", trailers):
52
+ {style}
53
+
54
+ Changed files{which}:
55
+ {files}
56
+
57
+ {diff}
58
+
59
+ Reply with JSON. "message" is the whole commit message: a subject line of at most 72 characters and, when the change needs explaining, a blank line and a body wrapped at 72 characters that says why, not what the diff already shows. {files_rule} Use what you know from this conversation about why the changes were made. Don't mention yourself or lcode."""
60
+
61
+ FILES_CHOSEN = """"files" lists the files that belong in this commit, from the list above: leave out files unrelated to the work (scratch files, logs, local experiments). When in doubt, include a file."""
62
+ FILES_STAGED = """The user staged exactly what to commit, so "files" is an empty list."""
63
+
64
+ PR_PROMPT = """Write a pull request for the branch's changes below, into {base}.{hint}
65
+
66
+ Commits:
67
+ {commits}
68
+
69
+ {diff}
70
+ {template}
71
+ Reply with JSON. "title" is a short title (at most 70 characters) in the style of the commits. "body" is the description in Markdown: what changes and why, and how it was tested when you know. Keep it short and concrete; no headings for a small change. {branch_rule} Use what you know from this conversation. Don't mention yourself or lcode."""
72
+
73
+ REVIEW_PROMPT = """Review {what} before it's {next}.
74
+
75
+ {stat}
76
+ {diff}
77
+
78
+ Look for:
79
+ 1. Bugs: wrong logic, missed edge cases, error handling, wrong assumptions about inputs, races.
80
+ 2. Tests: changed behaviour without a test, tests that don't check what they claim.
81
+ 3. Security: injection, secrets in the code, unchecked input, too-broad permissions.
82
+ 4. Clarity: confusing names, dead code, duplication, comments that no longer match the code. Only what matters.
83
+
84
+ Read the surrounding code where you need it: a diff alone can mislead. Don't change any files: this is a review. If the project's instructions or a skill describe how to review here, follow them.
85
+
86
+ Report each finding as `path:line`, its kind (bug, test, security or clarity), what's wrong and how to fix it, most important first. If nothing needs changing, say so in a sentence; don't invent problems."""
87
+
88
+
89
+ class GitError(Exception):
90
+ pass
91
+
92
+
93
+ def git(cwd: Path, *args: str, check: bool = True, stdin: str | None = None, timeout: float = 120) -> str:
94
+ try:
95
+ r = subprocess.run(
96
+ ["git", *args], cwd=cwd, input=stdin, capture_output=True, text=True, timeout=timeout
97
+ ) # fmt: skip
98
+ except (OSError, subprocess.SubprocessError) as e:
99
+ raise GitError(f"git {args[0]} failed: {e}") from e
100
+ if r.returncode != 0 and check:
101
+ raise GitError((r.stderr or r.stdout).strip() or f"git {args[0]} failed")
102
+ return r.stdout
103
+
104
+
105
+ def repo_root(cwd: Path) -> Path:
106
+ try:
107
+ return Path(git(cwd, "rev-parse", "--show-toplevel").strip())
108
+ except GitError as e:
109
+ raise GitError("this isn't a git repository") from e
110
+
111
+
112
+ def has_commits(root: Path) -> bool:
113
+ return bool(git(root, "rev-parse", "--verify", "-q", "HEAD", check=False).strip())
114
+
115
+
116
+ # ----------------------------------------------------------------------------- changes
117
+
118
+
119
+ @dataclass
120
+ class Change:
121
+ path: str
122
+ code: str # git's two status letters, e.g. " M", "A ", "??"
123
+
124
+ @property
125
+ def staged(self) -> bool:
126
+ return self.code[0] not in " ?"
127
+
128
+ @property
129
+ def untracked(self) -> bool:
130
+ return self.code == "??"
131
+
132
+
133
+ def changes(root: Path) -> list[Change]:
134
+ out = git(root, "status", "--porcelain=v1", "-z", "--untracked-files=all")
135
+ found, parts, i = [], out.split("\0"), 0
136
+ while i < len(parts):
137
+ entry = parts[i]
138
+ i += 1
139
+ if len(entry) < 4:
140
+ continue
141
+ code, path = entry[:2], entry[3:]
142
+ if code[0] in "RC":
143
+ i += 1 # the old name follows
144
+ found.append(Change(path, code))
145
+ return found
146
+
147
+
148
+ def sensitive(path: str) -> bool:
149
+ name = Path(path).name.lower()
150
+ return any(fnmatch.fnmatch(name, pattern) for pattern in SENSITIVE)
151
+
152
+
153
+ def diff_budget(agent: Agent) -> int:
154
+ """How much diff (in characters) fits next to the conversation."""
155
+ room = int(agent.settings.context * 0.8) - agent.ctx_used - 3000
156
+ return max(MIN_DIFF_CHARS, min(MAX_DIFF_CHARS, room * 3))
157
+
158
+
159
+ def shorten(text: str, limit: int, what: str) -> str:
160
+ if len(text) <= limit:
161
+ return text
162
+ return text[:limit] + f"\n[… {len(text) - limit:,} more characters of the {what} not shown]"
163
+
164
+
165
+ def new_files(root: Path, paths: list[str]) -> str:
166
+ parts = []
167
+ for rel in paths:
168
+ path = root / rel
169
+ try:
170
+ if path.is_symlink() or not path.is_file() or path.stat().st_size > 1_000_000:
171
+ parts.append(f"new file: {rel} (not shown)")
172
+ continue
173
+ data = path.read_bytes()
174
+ except OSError:
175
+ continue
176
+ if b"\0" in data[:4096]:
177
+ parts.append(f"new file: {rel} (binary)")
178
+ continue
179
+ lines = data.decode(errors="replace").splitlines()
180
+ more = f"\n[… {len(lines) - MAX_UNTRACKED_LINES} more lines]" if len(lines) > MAX_UNTRACKED_LINES else ""
181
+ parts.append(f"new file: {rel}\n" + "\n".join(lines[:MAX_UNTRACKED_LINES]) + more)
182
+ return "\n\n".join(parts)
183
+
184
+
185
+ def changes_diff(root: Path, files: list[Change], staged_only: bool, limit: int) -> str:
186
+ """The diff of these changes, new files included, cut to `limit` characters."""
187
+ tracked = [c.path for c in files if not c.untracked]
188
+ text = ""
189
+ if staged_only:
190
+ text = git(root, "diff", "--cached", "--no-color")
191
+ elif tracked:
192
+ against = ["HEAD"] if has_commits(root) else ["--cached"]
193
+ text = git(root, "diff", "--no-color", *against, "--", *tracked)
194
+ untracked = new_files(root, [c.path for c in files if c.untracked])
195
+ return shorten("\n\n".join(p for p in (text.strip(), untracked) if p), limit, "diff")
196
+
197
+
198
+ def style(root: Path) -> str:
199
+ if not has_commits(root):
200
+ return "(no commits yet)"
201
+ subjects = git(root, "log", "-n", "15", "--no-merges", "--format=%s").strip()
202
+ bodies = git(root, "log", "-n", "3", "--no-merges", "--format=--- %B").strip()
203
+ return f"{subjects}\n\nThe latest three in full:\n{bodies}"
204
+
205
+
206
+ # ----------------------------------------------------------------------------- asking the model
207
+
208
+
209
+ def parse_json(content: str) -> dict:
210
+ for candidate in (content, *re.findall(r"\{.*\}", content, re.S)):
211
+ try:
212
+ data = json.loads(candidate)
213
+ except (json.JSONDecodeError, TypeError):
214
+ continue
215
+ if isinstance(data, dict):
216
+ return data
217
+ return {}
218
+
219
+
220
+ def ask(agent: Agent, request: str, schema: dict) -> dict:
221
+ """One structured answer, with the conversation as context; the request isn't kept in it."""
222
+ history = agent.messages
223
+ if agent.ctx_used + len(request) // 3 > agent.settings.context * 0.85:
224
+ history = agent.messages[:1] # too long: the instructions only
225
+ payload = {
226
+ "model": agent.settings.model,
227
+ "messages": [*history, {"role": "user", "content": request}],
228
+ "tools": agent.tool_schemas(), # the same as the session's, so the cached prompt is reused
229
+ "think": False,
230
+ "format": schema,
231
+ "keep_alive": agent.settings.keep_alive,
232
+ "options": agent.options(),
233
+ }
234
+ reply = agent.ollama.chat(payload)
235
+ return parse_json((reply.get("message") or {}).get("content") or "")
236
+
237
+
238
+ def clean_message(text: str) -> str:
239
+ text = re.sub(r"^```\w*\n|\n```$", "", text.strip()).strip()
240
+ return "\n".join(line.rstrip() for line in text.splitlines()).strip()
241
+
242
+
243
+ def confirm(agent: Agent, question: str, editable: str | None = None) -> str:
244
+ """'y', 'e' (when there's something to edit) or 'n'."""
245
+ try:
246
+ answer = input(question).strip().lower()
247
+ except (EOFError, KeyboardInterrupt):
248
+ agent.console.print()
249
+ return "n"
250
+ if answer in ("y", "yes"):
251
+ return "y"
252
+ if answer in ("e", "edit") and editable is not None:
253
+ return "e"
254
+ return "n"
255
+
256
+
257
+ # ----------------------------------------------------------------------------- /commit
258
+
259
+
260
+ def commit_command(agent: Agent, hint: str = "") -> None:
261
+ c = agent.console
262
+ try:
263
+ root = repo_root(agent.cwd)
264
+ found = changes(root)
265
+ except GitError as e:
266
+ c.print(f"[red]{escape(str(e))}[/]")
267
+ return
268
+ if not found:
269
+ c.print("Nothing to commit: the working tree is clean.")
270
+ return
271
+ staged = [ch for ch in found if ch.staged]
272
+ risky: list[str] = []
273
+ if staged:
274
+ candidates = staged
275
+ unstaged = len([ch for ch in found if not ch.staged])
276
+ c.print(
277
+ f"[dim]Committing the {len(staged)} staged file(s)"
278
+ + (f"; {unstaged} other changed file(s) stay out of it." if unstaged else ".")
279
+ + "[/]"
280
+ )
281
+ else:
282
+ risky = [ch.path for ch in found if sensitive(ch.path)]
283
+ candidates = [ch for ch in found if not sensitive(ch.path)]
284
+ if not candidates:
285
+ c.print(
286
+ f"Only files that may hold secrets changed ({escape(', '.join(risky))}). "
287
+ "If they belong in a commit, stage them yourself (git add) and run /commit again."
288
+ )
289
+ return
290
+ paths = [ch.path for ch in candidates]
291
+ request = COMMIT_PROMPT.format(
292
+ hint=f" The user adds: {hint}" if hint else "",
293
+ style=style(root),
294
+ which=" (staged)" if staged else "",
295
+ files="\n".join(f"{ch.code.strip() or 'M'} {ch.path}" for ch in candidates),
296
+ diff=changes_diff(root, candidates, bool(staged), diff_budget(agent)),
297
+ files_rule=FILES_STAGED if staged else FILES_CHOSEN,
298
+ )
299
+ from lcode.ollama import OllamaError
300
+
301
+ try:
302
+ with c.status("[cyan]Writing the commit message…[/]", spinner="dots"):
303
+ answer = ask(agent, request, COMMIT_SCHEMA)
304
+ except KeyboardInterrupt:
305
+ c.print("[dim]Cancelled.[/]")
306
+ return
307
+ except OllamaError as e:
308
+ c.print(f"[red]Couldn't write a commit message: {escape(str(e))}[/]")
309
+ return
310
+ message = clean_message(str(answer.get("message") or ""))
311
+ if not message:
312
+ c.print("[red]The model didn't write a commit message.[/] Try again, or commit yourself.")
313
+ return
314
+ files = paths
315
+ if not staged:
316
+ chosen = [f for f in answer.get("files") or [] if f in paths]
317
+ files = chosen or paths
318
+ left_out = [p for p in paths if p not in files]
319
+ while True:
320
+ c.print(Panel(escape(message), title="Commit message", title_align="left", border_style="cyan"))
321
+ c.print(f" [bold]Files[/] {escape(', '.join(files))}")
322
+ if left_out:
323
+ c.print(f" [dim]Left out (unrelated, the model thinks): {escape(', '.join(left_out))}[/]")
324
+ if risky:
325
+ c.print(f" [dim]Left out (may hold secrets; stage them yourself if needed): {escape(', '.join(risky))}[/]")
326
+ choice = confirm(agent, " [y] commit · [e] edit the message · [n] cancel: ", message)
327
+ if choice == "e":
328
+ from lcode.planning import edit_text
329
+
330
+ message = clean_message(edit_text(message)) or message
331
+ continue
332
+ break
333
+ if choice != "y":
334
+ c.print("Not committed.")
335
+ return
336
+ try:
337
+ if not staged:
338
+ git(root, "add", "-A", "--", *files)
339
+ git(root, "commit", "-q", "-F", "-", stdin=message + "\n", timeout=600) # commit hooks may run tests
340
+ summary = git(root, "log", "-1", "--format=%h %s").strip()
341
+ except GitError as e:
342
+ c.print(f"[red]git commit failed:[/] {escape(str(e))}")
343
+ return
344
+ c.print(f"[green]✓ Committed[/] {escape(summary)}")
345
+
346
+
347
+ # ----------------------------------------------------------------------------- /review
348
+
349
+
350
+ def default_branch(root: Path) -> str:
351
+ head = git(root, "symbolic-ref", "-q", "--short", "refs/remotes/origin/HEAD", check=False).strip()
352
+ if head:
353
+ return head.removeprefix("origin/")
354
+ for name in ("main", "master", "trunk", "develop"):
355
+ if git(root, "rev-parse", "--verify", "-q", name, check=False).strip():
356
+ return name
357
+ return "main"
358
+
359
+
360
+ def base_ref(root: Path, base: str, remote: str = "origin") -> str:
361
+ """<remote>/<base> when there is one (the local branch may be behind), else <base>."""
362
+ for ref in (f"{remote}/{base}", base):
363
+ if git(root, "rev-parse", "--verify", "-q", ref, check=False).strip():
364
+ return ref
365
+ raise GitError(f"there's no branch called {base}")
366
+
367
+
368
+ def is_branch(root: Path, name: str, remote: str = "origin") -> bool:
369
+ refs = (f"refs/heads/{name}", f"refs/remotes/{remote}/{name}")
370
+ return any(git(root, "rev-parse", "--verify", "-q", ref, check=False).strip() for ref in refs)
371
+
372
+
373
+ def review_prompt(agent: Agent, base: str = "") -> str:
374
+ """The request for a review of the uncommitted changes, or of the branch against `base`.
375
+
376
+ Uncommitted changes to tracked files are the work in progress, so they come first; otherwise
377
+ (or with a base) the branch's changes since it left the base. New files are shown either way.
378
+ """
379
+ root = repo_root(agent.cwd)
380
+ limit = diff_budget(agent)
381
+ found = changes(root)
382
+ untracked = [ch.path for ch in found if ch.untracked]
383
+ work_in_progress = any(not ch.untracked for ch in found)
384
+ commits = fork = ""
385
+ explicit = bool(base)
386
+ if base or (has_commits(root) and not work_in_progress):
387
+ if not has_commits(root):
388
+ raise GitError("nothing to compare with yet: the repository has no commits")
389
+ base = base or default_branch(root)
390
+ if is_branch(root, base):
391
+ fork = git(root, "merge-base", base_ref(root, base), "HEAD").strip()
392
+ elif explicit and git(root, "rev-parse", "--verify", "-q", base, check=False).strip():
393
+ fork = git(root, "merge-base", base, "HEAD").strip()
394
+ elif explicit:
395
+ raise GitError(f"there's no branch or commit called {base}")
396
+ if fork:
397
+ commits = git(root, "log", "--format=- %s", f"{fork}..HEAD").strip()
398
+ if fork and (commits or explicit or work_in_progress):
399
+ branch = git(root, "branch", "--show-current").strip() or "HEAD"
400
+ tracked = git(root, "diff", "--no-color", fork) # the branch's commits and any uncommitted changes
401
+ what, next_step = f"the changes on {branch} compared with {base}", "merged"
402
+ stat = (
403
+ f"Commits:\n{commits or '(none: uncommitted changes only)'}\n\n{git(root, 'diff', '--stat', fork).strip()}"
404
+ )
405
+ elif found:
406
+ tracked = git(root, "diff", "--no-color", "HEAD") if has_commits(root) else ""
407
+ what, next_step = "the uncommitted changes", "committed"
408
+ stat = git(root, "diff", "--stat", "HEAD").strip() if has_commits(root) else ""
409
+ else:
410
+ where = f" and nothing on this branch that {base} doesn't have" if base else ""
411
+ raise GitError(f"nothing to review: no uncommitted changes{where}")
412
+ if untracked:
413
+ stat += ("\n" if stat else "") + "New files, not yet added to git: " + ", ".join(untracked)
414
+ diff = "\n\n".join(p for p in (tracked.strip(), new_files(root, untracked)) if p)
415
+ if not diff:
416
+ raise GitError(f"nothing to review: {what} are empty")
417
+ return REVIEW_PROMPT.format(
418
+ what=what, next=next_step, stat=stat, diff=f"```diff\n{shorten(diff, limit, 'diff')}\n```"
419
+ )
420
+
421
+
422
+ # ----------------------------------------------------------------------------- /pr
423
+
424
+
425
+ def pr_command(agent: Agent, arg: str = "") -> None:
426
+ c = agent.console
427
+ if not shutil.which("gh"):
428
+ c.print("[red]/pr needs the GitHub CLI:[/] install it from https://cli.github.com and run `gh auth login`.")
429
+ return
430
+ try:
431
+ root = repo_root(agent.cwd)
432
+ if not git(root, "remote").split():
433
+ raise GitError("this repository has no remote to push to")
434
+ branch = git(root, "branch", "--show-current").strip()
435
+ if not branch:
436
+ raise GitError("HEAD isn't on a branch (detached); switch to one first")
437
+ uncommitted = [ch for ch in changes(root) if not ch.untracked]
438
+ if uncommitted:
439
+ c.print(
440
+ f"There are uncommitted changes ({len(uncommitted)} file(s)). Commit them first with /commit, "
441
+ "then run /pr again."
442
+ )
443
+ return
444
+ remotes = git(root, "remote").split()
445
+ remote = "origin" if "origin" in remotes else remotes[0]
446
+ first, _, rest = arg.strip().partition(" ")
447
+ if first and is_branch(root, first, remote): # /pr develop, or /pr develop <what to say>
448
+ base, hint = first, rest.strip()
449
+ else:
450
+ base, hint = default_branch(root), arg.strip()
451
+ ref = base_ref(root, base, remote)
452
+ commits = git(root, "log", "--format=- %s%n%b", f"{ref}..HEAD").strip()
453
+ except GitError as e:
454
+ c.print(f"[red]{escape(str(e))}[/]")
455
+ return
456
+ if not commits:
457
+ c.print(f"No commits to open a pull request with: {escape(branch)} has nothing that {escape(ref)} doesn't.")
458
+ return
459
+ existing = subprocess.run(
460
+ ["gh", "pr", "view", branch, "--json", "url,state", "-q", 'select(.state == "OPEN") | .url'],
461
+ cwd=root, capture_output=True, text=True, timeout=60,
462
+ ).stdout.strip() if branch != base else "" # fmt: skip
463
+ if existing:
464
+ c.print(f"A pull request for {escape(branch)} is already open: {existing}")
465
+ if confirm(agent, f" Push the new commits to {remote}/{branch}? [y] push · [n] no: ") == "y":
466
+ push(agent, root, remote, branch)
467
+ return
468
+ new_branch = branch == base # the commits are on the base branch: they go to a new branch
469
+ template = next(
470
+ (p for p in (root / ".github" / "pull_request_template.md", root / "docs" / "pull_request_template.md",
471
+ root / "PULL_REQUEST_TEMPLATE.md") if p.is_file()),
472
+ None,
473
+ ) # fmt: skip
474
+ request = PR_PROMPT.format(
475
+ base=base,
476
+ hint=f" The user adds: {hint}" if hint else "",
477
+ commits=commits,
478
+ diff=f"```diff\n{shorten(git(root, 'diff', '--no-color', f'{ref}...HEAD'), diff_budget(agent), 'diff')}\n```",
479
+ template=f"\nFill in the repository's pull request template:\n{template.read_text()[:4000]}\n"
480
+ if template
481
+ else "",
482
+ branch_rule=(
483
+ '"branch" is a short name for a new branch for these commits, such as fix-login-timeout.'
484
+ if new_branch
485
+ else '"branch" is an empty string.'
486
+ ),
487
+ )
488
+ from lcode.ollama import OllamaError
489
+
490
+ try:
491
+ with c.status("[cyan]Writing the pull request…[/]", spinner="dots"):
492
+ answer = ask(agent, request, PR_SCHEMA)
493
+ except KeyboardInterrupt:
494
+ c.print("[dim]Cancelled.[/]")
495
+ return
496
+ except OllamaError as e:
497
+ c.print(f"[red]Couldn't write the pull request: {escape(str(e))}[/]")
498
+ return
499
+ title = " ".join(str(answer.get("title") or "").split())
500
+ body = clean_message(str(answer.get("body") or ""))
501
+ if not title:
502
+ c.print("[red]The model didn't write a title.[/] Try again, or use gh pr create yourself.")
503
+ return
504
+ head = branch
505
+ if new_branch:
506
+ head = branch_name(str(answer.get("branch") or "")) or f"lcode-{time.strftime('%m%d')}-{secrets.token_hex(2)}"
507
+ while True:
508
+ c.print(
509
+ Panel(escape(f"{title}\n\n{body}".strip()), title="Pull request", title_align="left", border_style="cyan")
510
+ )
511
+ if new_branch:
512
+ c.print(f" [dim]The commits are on {escape(base)}, so they go to a new branch: {escape(head)}[/]")
513
+ choice = confirm(
514
+ agent,
515
+ f" Push {head} to {remote} and open the pull request into {base}? [y] yes · [e] edit · [n] no: ",
516
+ body,
517
+ )
518
+ if choice == "e":
519
+ from lcode.planning import edit_text
520
+
521
+ edited = edit_text(f"{title}\n\n{body}").strip()
522
+ if edited:
523
+ title, _, body = edited.partition("\n")
524
+ title, body = title.strip(), body.strip()
525
+ continue
526
+ break
527
+ if choice != "y":
528
+ c.print("No pull request opened.")
529
+ return
530
+ try:
531
+ if new_branch:
532
+ git(root, "switch", "-q", "-c", head)
533
+ c.print(f"[dim]Switched to the new branch {escape(head)}; {escape(base)} still has the commits locally.[/]")
534
+ if not push(agent, root, remote, head):
535
+ return
536
+ with tempfile.NamedTemporaryFile("w", suffix=".md", delete=False) as f:
537
+ f.write(body + "\n")
538
+ try:
539
+ r = subprocess.run(
540
+ ["gh", "pr", "create", "--base", base, "--head", head, "--title", title, "--body-file", f.name],
541
+ cwd=root, capture_output=True, text=True, timeout=120,
542
+ ) # fmt: skip
543
+ finally:
544
+ Path(f.name).unlink(missing_ok=True)
545
+ except (GitError, OSError, subprocess.SubprocessError) as e:
546
+ c.print(f"[red]{escape(str(e))}[/]")
547
+ return
548
+ if r.returncode != 0:
549
+ c.print(f"[red]gh pr create failed:[/] {escape((r.stderr or r.stdout).strip())}")
550
+ return
551
+ c.print(f"[green]✓ Opened[/] {r.stdout.strip().splitlines()[-1] if r.stdout.strip() else 'the pull request'}")
552
+
553
+
554
+ def push(agent: Agent, root: Path, remote: str, branch: str) -> bool:
555
+ try:
556
+ with agent.console.status(f"Pushing {escape(branch)}…", spinner="dots"):
557
+ git(root, "push", "-q", "-u", remote, branch, timeout=300)
558
+ except GitError as e:
559
+ agent.console.print(f"[red]git push failed:[/] {escape(str(e))}")
560
+ return False
561
+ agent.console.print(f"[green]✓ Pushed[/] {escape(branch)}")
562
+ return True
563
+
564
+
565
+ def branch_name(text: str) -> str:
566
+ name = re.sub(r"[^A-Za-z0-9._/-]+", "-", text.strip()).strip("-/.")
567
+ return re.sub(r"-{2,}", "-", name)[:60]
568
+
569
+
570
+ # ----------------------------------------------------------------------------- lcode --worktree
571
+
572
+
573
+ @dataclass
574
+ class WorktreeSession:
575
+ root: Path # the repository's main working tree
576
+ path: Path
577
+ branch: str
578
+ base: str # the commit the main working tree was on when the session started
579
+ created_branch: bool
580
+ cwd: Path
581
+
582
+ def finish(self) -> str:
583
+ """Remove the worktree when nothing changed in it; otherwise say where the work is."""
584
+ try:
585
+ dirty = git(self.path, "status", "--porcelain").strip()
586
+ ahead = int(git(self.root, "rev-list", "--count", f"{self.base}..{self.branch}").strip() or 0)
587
+ except (GitError, ValueError):
588
+ return f"The worktree stays at {self.path}."
589
+ if not dirty and not ahead:
590
+ git(self.root, "worktree", "remove", str(self.path), check=False)
591
+ if self.created_branch:
592
+ git(self.root, "branch", "-D", self.branch, check=False)
593
+ return f"Nothing changed in the worktree, so it was removed{' with its branch' if self.created_branch else ''}."
594
+ parts = [f"{ahead} commit(s)" if ahead else "", "uncommitted changes" if dirty else ""]
595
+ return (
596
+ f"The worktree stays at {self.path} (branch {self.branch}: {' and '.join(p for p in parts if p)}). "
597
+ f"Continue there with `lcode --worktree {self.branch}`; merge with `git merge {self.branch}`; "
598
+ f"remove it with `git worktree remove {self.path}`."
599
+ )
600
+
601
+
602
+ def main_checkout(cwd: Path) -> Path:
603
+ """The repository's main working tree, also from inside one of its worktrees."""
604
+ root = repo_root(cwd)
605
+ common = Path(git(root, "rev-parse", "--path-format=absolute", "--git-common-dir").strip())
606
+ return common.parent if common.name == ".git" else root
607
+
608
+
609
+ def worktree_path(root: Path, name: str) -> Path:
610
+ """Outside the repository: inside it, the main checkout's path would lead models astray."""
611
+ from lcode import config
612
+ from lcode.memory import slugify
613
+
614
+ key = hashlib.sha256(str(root.resolve()).encode()).hexdigest()[:8]
615
+ return config.STATE_DIR / "worktrees" / f"{slugify(root.name, 40) or 'repo'}-{key}" / name.replace("/", "-")
616
+
617
+
618
+ def start_worktree(cwd: Path, name: str) -> WorktreeSession:
619
+ """A worktree on its own branch, for a session that works in parallel with others."""
620
+ root = main_checkout(cwd)
621
+ if not has_commits(root):
622
+ raise GitError("a worktree needs a repository with at least one commit")
623
+ name = name or f"lcode-{time.strftime('%m%d')}-{secrets.token_hex(2)}"
624
+ if git(root, "check-ref-format", "--branch", name, check=False).strip() != name:
625
+ raise GitError(f"{name!r} isn't a valid branch name")
626
+ path = worktree_path(root, name)
627
+ base = git(root, "rev-parse", "HEAD").strip()
628
+ created = False
629
+ if not (path / ".git").exists():
630
+ path.parent.mkdir(parents=True, exist_ok=True)
631
+ git(root, "worktree", "prune") # forget worktrees whose folders were deleted
632
+ if git(root, "rev-parse", "--verify", "-q", f"refs/heads/{name}", check=False).strip():
633
+ git(root, "worktree", "add", "-q", str(path), name)
634
+ else:
635
+ git(root, "worktree", "add", "-q", "-b", name, str(path), "HEAD")
636
+ created = True
637
+ here = cwd.resolve()
638
+ sub = here.relative_to(root.resolve()) if here.is_relative_to(root.resolve()) else Path()
639
+ inside = path / sub
640
+ return WorktreeSession(root, path, name, base, created, inside if inside.is_dir() else path)
lcode/repl.py CHANGED
@@ -26,7 +26,7 @@ from rich.rule import Rule
26
26
  from rich.table import Table
27
27
  from rich.text import Text
28
28
 
29
- from lcode import __version__, backends, catalog, extensions, limits, sessions, web
29
+ from lcode import __version__, backends, catalog, extensions, gitflow, limits, sessions, web
30
30
  from lcode import context as context_tools
31
31
  from lcode import memory as memory_notes
32
32
  from lcode.agent import AUTO_COMPACT_RATIO, INIT_PROMPT, Agent
@@ -62,8 +62,12 @@ COMMANDS = {
62
62
  "/mode": "Permission mode: ask | plan | auto-edit | yolo (Shift+Tab cycles)",
63
63
  "/cd": "Change the working directory",
64
64
  "/todos": "Show the current todo list",
65
+ "/commit": "Commit the changes with a message in the repository's style, after you approve it",
66
+ "/review": "Review the uncommitted changes, or the branch against a base: /review [base]",
67
+ "/pr": "Push the branch and open a GitHub pull request, after you approve it: /pr [base] [notes]",
65
68
  "/exit": "Quit",
66
69
  }
70
+ REPLACEABLE = {"/commit", "/review", "/pr"} # a command of your own with the same name runs instead
67
71
 
68
72
 
69
73
  class InputCompleter(Completer):
@@ -420,7 +424,11 @@ def custom_commands(agent: Agent) -> dict[str, str]:
420
424
  ext = agent.extensions()
421
425
  found = {f"/{name}": skill.description for name, skill in ext.skills.items()}
422
426
  found.update({f"/{name}": command.description for name, command in ext.commands.items()})
423
- return {name: description for name, description in found.items() if name not in COMMANDS}
427
+ return {
428
+ name: description
429
+ for name, description in found.items()
430
+ if name not in COMMANDS or (name in REPLACEABLE and name[1:] in ext.commands)
431
+ }
424
432
 
425
433
 
426
434
  def help_extensions(agent: Agent) -> None:
@@ -430,7 +438,11 @@ def help_extensions(agent: Agent) -> None:
430
438
  for column in ("Command", "Does", "From"):
431
439
  table.add_column(column)
432
440
  for command in ext.commands.values():
433
- hidden = " [yellow](hidden by the built-in command)[/]" if f"/{command.name}" in COMMANDS else ""
441
+ hidden = ""
442
+ if f"/{command.name}" in REPLACEABLE:
443
+ hidden = " [dim](replaces the built-in command)[/]"
444
+ elif f"/{command.name}" in COMMANDS:
445
+ hidden = " [yellow](hidden by the built-in command)[/]"
434
446
  usage = f"/{command.name}" + (f" {command.argument_hint}" if command.argument_hint else "")
435
447
  table.add_row(escape(usage), escape(command.description) + hidden, escape(agent.tools.rel(command.path)))
436
448
  c.print(table)
@@ -767,6 +779,27 @@ def web_status(agent: Agent) -> str:
767
779
  LONG_REQUEST = 30 # seconds: a request this long sends a notification when it's done (notification hooks)
768
780
 
769
781
 
782
+ def git_command(agent: Agent, cmd: str, arg: str) -> None:
783
+ """/commit, /review and /pr (a command of your own with the same name replaces them)."""
784
+ c = agent.console
785
+ if cmd == "/commit":
786
+ gitflow.commit_command(agent, arg)
787
+ elif cmd == "/pr":
788
+ gitflow.pr_command(agent, arg)
789
+ else:
790
+ try:
791
+ with c.status("Collecting the changes…", spinner="dots"):
792
+ prompt = gitflow.review_prompt(agent, arg)
793
+ except gitflow.GitError as e:
794
+ c.print(f"[yellow]{escape(str(e))}[/]")
795
+ return
796
+ agent.no_changes = "This is a review, so nothing can be changed: report what should change instead."
797
+ try:
798
+ run_safely(agent, prompt)
799
+ finally:
800
+ agent.no_changes = ""
801
+
802
+
770
803
  def run_safely(agent: Agent, text: str) -> None:
771
804
  started = time.monotonic()
772
805
  try:
@@ -900,6 +933,8 @@ def handle_command(agent: Agent, line: str, hardware: Hardware) -> bool:
900
933
  agent.tools.show_todos()
901
934
  elif cmd == "/init":
902
935
  run_safely(agent, INIT_PROMPT)
936
+ elif cmd in REPLACEABLE and cmd[1:] not in agent.extensions().commands:
937
+ git_command(agent, cmd, arg)
903
938
  elif cmd[1:] in agent.extensions().commands:
904
939
  command = agent.extensions().commands[cmd[1:]]
905
940
  c.print(f"[dim]/{escape(command.name)} from {escape(agent.tools.rel(command.path))}[/]")
lcode/tools.py CHANGED
@@ -440,6 +440,8 @@ class Toolbox:
440
440
  return f"Error: {name} isn't available to you. Available: {', '.join(sorted(allowed))}"
441
441
  if self.agent.planning() and name not in PLAN_MODE_TOOLS:
442
442
  return f"Error: {BLOCKED}"
443
+ if self.agent.no_changes and name not in PLAN_MODE_TOOLS:
444
+ return f"Error: {self.agent.no_changes}"
443
445
  if name == "present_plan" and not self.agent.planning():
444
446
  return "Error: plan mode is off, so there's nothing to present: carry on with the work."
445
447
  if self.agent.mcp and self.agent.mcp.owns(name):
@@ -484,6 +486,8 @@ class Toolbox:
484
486
  kind = self.agent.agent_types().get(type)
485
487
  if self.agent.planning() and kind and not kind.read_only:
486
488
  raise ToolError(f"plan mode is on: only read-only agents can run now ({BLOCKED})")
489
+ if self.agent.no_changes and kind and not kind.read_only:
490
+ raise ToolError(f"only read-only agents can run now: {self.agent.no_changes}")
487
491
  return subagents.run_one(self.agent, {"type": type, "task": task, "description": description})
488
492
 
489
493
  # -- plan mode
@@ -777,6 +781,8 @@ class Toolbox:
777
781
  sandbox = self.agent.sandbox
778
782
  if self.agent.planning() and not is_read_only(command):
779
783
  raise ToolError(f"{BLOCKED} Until then, only read-only commands run (ls, cat, grep, git log, …).")
784
+ if self.agent.no_changes and not is_read_only(command):
785
+ raise ToolError(f"{self.agent.no_changes} Only read-only commands run (ls, cat, grep, git diff, …).")
780
786
  if self.agent.read_only and not is_read_only(command):
781
787
  raise ToolError(
782
788
  "you can only run read-only commands (such as ls, cat, grep, find, git log, git diff), one at a "
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.16.0
3
+ Version: 0.17.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/
@@ -101,6 +101,9 @@ through [Ollama](https://ollama.com), and when it needs current information it c
101
101
  - **Remembers what matters.** Corrections, decisions and where things live carry over to the next
102
102
  session as short notes, saved by the model, by you (`/remember`) or by a quick check at the end
103
103
  of a session. Not whole conversations, and never secrets.
104
+ - **Git workflow.** `/commit` writes messages in your repository's style, `/review` checks the
105
+ changes with `file:line` findings, `/pr` opens the pull request, each after you approve it, and
106
+ `lcode --worktree` runs parallel sessions on one repository.
104
107
  - **Bring your own model.** Eight curated open-weight models, all tested end to end, or any Ollama model
105
108
  with tool calling. Or use LM Studio, llama.cpp, vLLM or MLX instead of Ollama.
106
109
  - **MCP servers, ready to go.** Connect Jira and Confluence, GitHub, AWS, Google Drive, Grafana,
@@ -208,8 +211,8 @@ fills up. Details: [How it works](https://nasser1941.github.io/lcode/how-it-work
208
211
 
209
212
  See the pinned [Roadmap issue](https://github.com/nasser1941/lcode/issues/10). Next up:
210
213
 
211
- - [Git workflow: /commit, /review, /pr and parallel worktrees](https://github.com/nasser1941/lcode/issues/50)
212
214
  - [Automation: JSON output, a Python API and a GitHub Action for self-hosted runners](https://github.com/nasser1941/lcode/issues/48)
215
+ - [Editor integration through the Agent Client Protocol](https://github.com/nasser1941/lcode/issues/49)
213
216
  - More models in the catalog, and test reports from your hardware ([report one](https://github.com/nasser1941/lcode/issues/new?template=model_request.yml))
214
217
 
215
218
  ## Contributing
@@ -1,17 +1,18 @@
1
- lcode/__init__.py,sha256=zacfVVvqhWmN_-V62Fl2VjpnxCnSmt1Y75XJ4_2KJ64,118
1
+ lcode/__init__.py,sha256=m94TcHxJbJ3MaEvHqOhWfWdGVWdgYlJoeSId7er_5-Q,118
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
- lcode/agent.py,sha256=ZApMmW8TE4SMD5nc2TCE7M0U_VCwHq1xIl97hmFGcfo,46262
3
+ lcode/agent.py,sha256=sFt7pfwvP0DMxIySznhWDyOLT7fujQa6jBMl9tQ2j8M,46979
4
4
  lcode/backends.py,sha256=dtJHrC6ys73bRDg64z0tQz1PavmeAkiODZu1fOO9ExQ,20635
5
5
  lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
6
6
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
7
7
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
8
8
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
9
- lcode/cli.py,sha256=QAnmYqJ36Wyhp5WLI0DC6S-KIVgzx8vZ8Iv-rQLmqEk,40289
9
+ lcode/cli.py,sha256=KjUezZD83VzSdLZEXMzSJO0I-mvNQSwSQdZgFpyGc-4,41090
10
10
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
11
11
  lcode/config.py,sha256=nFljzjHTFaCTTbagycagbh2X_PpLtsUs-yRODiy4QcU,10065
12
12
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
13
13
  lcode/extensions.py,sha256=W0HFDl0BvROzr7i_qtabHdIbacBgP5o5qIJLzoM2oNE,16806
14
14
  lcode/frontmatter.py,sha256=Bx0n4vLPhp7ZCWx-g7xqzqNy5VoKBxyeeikh5UFB_gA,2441
15
+ lcode/gitflow.py,sha256=y78dqqh80is-MiDTnSHnK9vjx0vFovor_SZ8hW1NJaU,27167
15
16
  lcode/hardware.py,sha256=2VbknZCxTZJwJUD8YWdQ7ryEQo_LcjtoOBOHOU9Y0fw,3085
16
17
  lcode/hooks.py,sha256=K0itOiAuH4rxndJ9gne_uCrqby6oWAzFPRrfB3tCq-E,9324
17
18
  lcode/limits.py,sha256=oJzVQoMyTZ0_Fkpu5t3qxrf9v7Hv9S8U8BRQR5M1aKA,1237
@@ -22,12 +23,12 @@ lcode/ollama.py,sha256=u5tnvL28cfaFS4GhmiEsHhTQe6w3Ng_Tej05UUzLQTQ,7715
22
23
  lcode/permissions.py,sha256=bSDpkSzq1tozC3LgzAzXmJX_DGRxoY9a3Kdx-AFQ3nI,5426
23
24
  lcode/planning.py,sha256=hZjVka7i9TswJrXLbjbZJ0PBc6zHpmO64JqMiaFYnMQ,6110
24
25
  lcode/render.py,sha256=gMvf4W3qkLUJ4Vot3az_hP_zRDAItJC9pP-68dHFkfA,1375
25
- lcode/repl.py,sha256=PRO4VG-s8kSowcHJ3svYHpHFBU0s4nsvsth8dyKG6mE,46117
26
+ lcode/repl.py,sha256=zJ2hj1c-tx35Sms8V-1W4XsyfFZ9omu9bZgGpEgBYi8,47655
26
27
  lcode/repomap.py,sha256=1GRJgEUjBl9XHSXsF7tSGXBCEgsY-3CzwMohaTudZ_M,13969
27
28
  lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
28
29
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
29
30
  lcode/subagents.py,sha256=kfDICxAYd4i9LtBFxTO9tdvbAB5JzF_iQXUUK9i6yc4,28813
30
- lcode/tools.py,sha256=ris8gXJ2phFY9Fr4UnUVkuyqjMRWMWONZc7onO7O-9c,41904
31
+ lcode/tools.py,sha256=zpFFY3dK8-g9So_MmUoj7qU8Stmx5b6PQrpcNba-s6E,42360
31
32
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
32
33
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
33
34
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -45,8 +46,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
45
46
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
46
47
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
47
48
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
48
- lcode_cli-0.16.0.dist-info/METADATA,sha256=vHaeTD3ZW6w2pDT_LhDDlfyVYrIa3lprUbWUof4WJtE,12126
49
- lcode_cli-0.16.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
50
- lcode_cli-0.16.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
51
- lcode_cli-0.16.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
52
- lcode_cli-0.16.0.dist-info/RECORD,,
49
+ lcode_cli-0.17.0.dist-info/METADATA,sha256=dDApqW2hYQQr89fUFigiyVph4Ib-DyYO4Ho1_zggtS4,12376
50
+ lcode_cli-0.17.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
51
+ lcode_cli-0.17.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
52
+ lcode_cli-0.17.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
53
+ lcode_cli-0.17.0.dist-info/RECORD,,