lcode-cli 0.20.0__py3-none-any.whl → 0.21.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.20.0"
6
+ __version__ = "0.21.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
lcode/cli.py CHANGED
@@ -678,7 +678,7 @@ def build_parser() -> argparse.ArgumentParser:
678
678
  parser = argparse.ArgumentParser(
679
679
  prog="lcode",
680
680
  description="A local-first terminal coding agent powered by open-weight models via Ollama.",
681
- epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index "
681
+ epilog="Subcommands: lcode setup | models | doctor | config | bench | mcp | index | update "
682
682
  "(lcode <subcommand> --help). "
683
683
  "Docs: https://nasser1941.github.io/lcode/",
684
684
  )
@@ -774,6 +774,15 @@ 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
+ p = argparse.ArgumentParser(
778
+ prog="lcode update",
779
+ description="Update lcode, and Ollama when it runs in Docker (to the newest version on Docker Hub, pinned "
780
+ "to its version tag).",
781
+ )
782
+ p.add_argument("what", nargs="?", choices=["lcode", "ollama"], help="only this one (default: both)")
783
+ p.add_argument("--check", action="store_true", help="only show which versions are out")
784
+ p.add_argument("-y", "--yes", action="store_true", help="don't ask before updating")
785
+ subs["update"] = p
777
786
  subs["acp"] = argparse.ArgumentParser(
778
787
  prog="lcode acp",
779
788
  description="Run as the agent inside an editor, over the Agent Client Protocol on stdin and stdout "
@@ -787,6 +796,12 @@ def build_subparsers() -> dict[str, argparse.ArgumentParser]:
787
796
  return subs
788
797
 
789
798
 
799
+ def cmd_update(args) -> None:
800
+ from lcode import update
801
+
802
+ sys.exit(update.command(console, args.what or "", check=args.check, yes=args.yes))
803
+
804
+
790
805
  def cmd_acp(args) -> None:
791
806
  from lcode import acp
792
807
 
@@ -863,6 +878,7 @@ COMMANDS = {
863
878
  "index": cmd_index,
864
879
  "action": cmd_action,
865
880
  "acp": cmd_acp,
881
+ "update": cmd_update,
866
882
  }
867
883
 
868
884
 
lcode/update.py ADDED
@@ -0,0 +1,557 @@
1
+ """`lcode update`: update lcode, and Ollama when it runs in Docker.
2
+
3
+ lcode updates itself the way it was installed: uv tool, pipx, Homebrew, pip, or `git pull` for a
4
+ checkout (an editable install).
5
+
6
+ For Ollama in Docker, the newest version is looked up on Docker Hub, never taken from a local
7
+ `latest` tag (which can be months old), and pulled by its version tag (`ollama/ollama:0.35.1`), so
8
+ `docker ps` shows which version runs. Release candidates are skipped, and a ROCm image stays ROCm.
9
+ - A container from Docker Compose gets the new tag in its compose file and is recreated with
10
+ `docker compose up`, so Compose keeps managing it.
11
+ - Any other container is recreated with the same name, ports, volumes, environment, GPUs, network
12
+ and restart policy. The old one is kept until the new one answers with the new version, and is
13
+ put back if it doesn't.
14
+ Ollama installed without Docker gets the command to update it, which lcode doesn't run itself.
15
+ """
16
+
17
+ from __future__ import annotations
18
+
19
+ import importlib.metadata
20
+ import json
21
+ import re
22
+ import shutil
23
+ import subprocess
24
+ import sys
25
+ import time
26
+ from collections.abc import Callable
27
+ from dataclasses import dataclass, field
28
+ from pathlib import Path
29
+ from urllib.parse import urlsplit
30
+
31
+ import requests
32
+
33
+ from lcode import __version__
34
+
35
+ IMAGE = "ollama/ollama"
36
+ HUB_TAGS = "https://hub.docker.com/v2/namespaces/ollama/repositories/ollama/tags"
37
+ PYPI = "https://pypi.org/pypi/lcode-cli/json"
38
+ SEMVER = re.compile(r"\d+\.\d+\.\d+")
39
+ START_TIMEOUT = 120 # seconds for a recreated container to answer
40
+ NATIVE = {
41
+ "linux": "curl -fsSL https://ollama.com/install.sh | sh",
42
+ "darwin": "brew upgrade ollama (or let the Ollama app update itself)",
43
+ }
44
+
45
+
46
+ class UpdateError(Exception):
47
+ pass
48
+
49
+
50
+ def version_key(version: str) -> tuple[int, ...]:
51
+ return tuple(int(p) for p in re.findall(r"\d+", version)[:3])
52
+
53
+
54
+ def run(argv: list[str], cwd: Path | None = None, timeout: float = 120) -> subprocess.CompletedProcess:
55
+ """A command whose output lcode reads (and tests replace)."""
56
+ try:
57
+ return subprocess.run(argv, cwd=cwd, capture_output=True, text=True, timeout=timeout)
58
+ except (OSError, subprocess.SubprocessError) as e:
59
+ raise UpdateError(f"{argv[0]} failed: {e}") from e
60
+
61
+
62
+ def stream(argv: list[str], cwd: Path | None = None) -> int:
63
+ """A command the user watches (pulls, installs); returns its exit code."""
64
+ try:
65
+ return subprocess.call(argv, cwd=cwd)
66
+ except OSError as e:
67
+ raise UpdateError(f"{argv[0]} failed: {e}") from e
68
+
69
+
70
+ def get_json(url: str, **params) -> dict:
71
+ try:
72
+ r = requests.get(url, params=params or None, timeout=20)
73
+ r.raise_for_status()
74
+ return r.json()
75
+ except (requests.RequestException, ValueError) as e:
76
+ raise UpdateError(f"couldn't read {url.split('?')[0]}: {e}") from e
77
+
78
+
79
+ # ----------------------------------------------------------------------------- the newest versions
80
+
81
+
82
+ def latest_lcode() -> str:
83
+ return str(get_json(PYPI)["info"]["version"])
84
+
85
+
86
+ def latest_ollama(flavor: str = "") -> str:
87
+ """The newest released Ollama image tag on Docker Hub: "0.35.1", or "0.35.1-rocm" for flavor "rocm"."""
88
+ names: list[str] = []
89
+ url, params = HUB_TAGS, {"page_size": 100, "ordering": "last_updated"}
90
+ for _ in range(3): # the newest tags come first; three pages reach well past them
91
+ page = get_json(url, **params)
92
+ names += [str(t.get("name", "")) for t in page.get("results") or []]
93
+ url, params = page.get("next"), {}
94
+ if not url:
95
+ break
96
+ suffix = f"-{flavor}" if flavor else ""
97
+ versions = [
98
+ n.removesuffix(suffix) for n in names if n.endswith(suffix) and SEMVER.fullmatch(n.removesuffix(suffix))
99
+ ]
100
+ if not versions:
101
+ raise UpdateError(f"Docker Hub lists no released {IMAGE} versions{f' for {flavor}' if flavor else ''}")
102
+ return max(versions, key=version_key) + suffix
103
+
104
+
105
+ def image_flavor(image: str) -> str:
106
+ """The image variant to stay on: "rocm" for ollama/ollama:rocm or :0.35.1-rocm, else ""."""
107
+ tag = image.rsplit(":", 1)[1] if ":" in image.rsplit("/", 1)[-1] else "latest"
108
+ return "rocm" if tag == "rocm" or tag.endswith("-rocm") else ""
109
+
110
+
111
+ # ----------------------------------------------------------------------------- lcode itself
112
+
113
+
114
+ @dataclass
115
+ class Install:
116
+ kind: str # checkout, uv, pipx, brew, uvx, pip
117
+ command: list[str] | None = None # what updates it
118
+ path: Path | None = None # the checkout, for kind checkout
119
+
120
+ def describe(self) -> str:
121
+ return {
122
+ "checkout": f"a git checkout at {self.path}",
123
+ "uv": "uv tool",
124
+ "pipx": "pipx",
125
+ "brew": "Homebrew",
126
+ "uvx": "uvx",
127
+ "pip": "pip",
128
+ }[self.kind]
129
+
130
+
131
+ def lcode_install(prefix: Path | None = None, direct_url: str | None = None) -> Install:
132
+ """How this lcode was installed."""
133
+ prefix = (prefix or Path(sys.prefix)).resolve()
134
+ if direct_url is None:
135
+ try:
136
+ direct_url = importlib.metadata.distribution("lcode-cli").read_text("direct_url.json") or ""
137
+ except importlib.metadata.PackageNotFoundError:
138
+ direct_url = ""
139
+ try:
140
+ origin = json.loads(direct_url) if direct_url else {}
141
+ except ValueError:
142
+ origin = {}
143
+ if (origin.get("dir_info") or {}).get("editable") and str(origin.get("url", "")).startswith("file://"):
144
+ return Install("checkout", path=Path(urlsplit(origin["url"]).path))
145
+ text = str(prefix)
146
+ if (prefix / "uv-receipt.toml").exists():
147
+ return Install("uv", ["uv", "tool", "upgrade", "lcode-cli"])
148
+ if "/Cellar/lcode/" in text:
149
+ return Install("brew", ["brew", "upgrade", "nasser1941/tap/lcode"])
150
+ if "pipx" in prefix.parts:
151
+ return Install("pipx", ["pipx", "upgrade", "lcode-cli"])
152
+ if "/uv/" in text and ("archive-v" in text or "/cache/" in text or "/.cache/" in text):
153
+ return Install("uvx")
154
+ return Install("pip", [sys.executable, "-m", "pip", "install", "--upgrade", "lcode-cli"])
155
+
156
+
157
+ @dataclass
158
+ class CheckoutState:
159
+ branch: str
160
+ dirty: bool
161
+ behind: int
162
+ upstream: str
163
+
164
+
165
+ def checkout_state(path: Path) -> CheckoutState:
166
+ def git(*args: str) -> str:
167
+ r = run(["git", "-C", str(path), *args], timeout=120)
168
+ if r.returncode != 0:
169
+ raise UpdateError(f"git {args[0]} failed in {path}: {(r.stderr or r.stdout).strip()[:200]}")
170
+ return r.stdout.strip()
171
+
172
+ branch = git("rev-parse", "--abbrev-ref", "HEAD")
173
+ dirty = bool(git("status", "--porcelain", "--untracked-files=no"))
174
+ upstream = run(["git", "-C", str(path), "rev-parse", "--abbrev-ref", "@{upstream}"]).stdout.strip()
175
+ behind = 0
176
+ if upstream:
177
+ git("fetch", "-q")
178
+ behind = int(git("rev-list", "--count", "HEAD..@{upstream}") or 0)
179
+ return CheckoutState(branch, dirty, behind, upstream)
180
+
181
+
182
+ def update_checkout(path: Path, install_receipt: bool) -> str:
183
+ """git pull a checkout lcode runs from; reinstall its environment when its dependencies changed."""
184
+ before = run(["git", "-C", str(path), "rev-parse", "HEAD"]).stdout.strip()
185
+ if stream(["git", "-C", str(path), "pull", "--ff-only"]) != 0:
186
+ raise UpdateError(f"git pull failed in {path}")
187
+ changed = run(["git", "-C", str(path), "diff", "--name-only", before, "HEAD"]).stdout.split()
188
+ reinstall = ["uv", "tool", "install", "--force", "--editable", str(path)]
189
+ if "pyproject.toml" in changed and install_receipt and stream(reinstall) != 0:
190
+ raise UpdateError("reinstalling lcode's environment failed: run uv tool install --force --editable .")
191
+ count = run(["git", "-C", str(path), "rev-list", "--count", f"{before}..HEAD"]).stdout.strip() or "0"
192
+ return f"{count} new commit(s)"
193
+
194
+
195
+ # ----------------------------------------------------------------------------- Ollama in Docker
196
+
197
+
198
+ @dataclass
199
+ class Container:
200
+ id: str
201
+ name: str
202
+ image: str # as configured, e.g. ollama/ollama:latest
203
+ info: dict = field(repr=False)
204
+
205
+ @property
206
+ def compose(self) -> dict | None:
207
+ labels = (self.info.get("Config") or {}).get("Labels") or {}
208
+ project, service = labels.get("com.docker.compose.project"), labels.get("com.docker.compose.service")
209
+ if not project or not service:
210
+ return None
211
+ files = [f for f in str(labels.get("com.docker.compose.project.config_files", "")).split(",") if f]
212
+ return {"project": project, "service": service, "files": files,
213
+ "workdir": labels.get("com.docker.compose.project.working_dir", "")} # fmt: skip
214
+
215
+
216
+ def docker_available() -> bool:
217
+ return shutil.which("docker") is not None and run(["docker", "info", "--format", "{{.ID}}"]).returncode == 0
218
+
219
+
220
+ def find_container(host: str) -> Container | None:
221
+ """The Ollama container that serves `host` (a local address), if Ollama runs in Docker."""
222
+ parts = urlsplit(host)
223
+ if parts.hostname not in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None):
224
+ return None
225
+ port = str(parts.port or 11434)
226
+ ids = run(["docker", "ps", "-q"]).stdout.split()
227
+ if not ids:
228
+ return None
229
+ infos = json.loads(run(["docker", "inspect", *ids]).stdout or "[]")
230
+ found = []
231
+ for info in infos:
232
+ image = (info.get("Config") or {}).get("Image", "")
233
+ if not image.startswith(IMAGE):
234
+ continue
235
+ bindings = ((info.get("HostConfig") or {}).get("PortBindings") or {}).get("11434/tcp") or []
236
+ if any(str(b.get("HostPort")) == port for b in bindings):
237
+ found.append(Container(info["Id"][:12], info["Name"].lstrip("/"), image, info))
238
+ return found[0] if len(found) == 1 else None
239
+
240
+
241
+ def set_compose_image(text: str, service: str, image: str) -> tuple[str, str]:
242
+ """The compose file with the service's image changed, and the image it had."""
243
+ lines = text.splitlines(keepends=True)
244
+ services = next((i for i, line in enumerate(lines) if re.match(r"services:\s*(#.*)?$", line)), None)
245
+ if services is None:
246
+ raise UpdateError("no services: section")
247
+ start = indent = None
248
+ for i in range(services + 1, len(lines)):
249
+ m = re.match(rf"(\s+)['\"]?{re.escape(service)}['\"]?:\s*(#.*)?$", lines[i])
250
+ if m:
251
+ start, indent = i, len(m.group(1))
252
+ break
253
+ if lines[i].strip() and not lines[i][0].isspace() and not lines[i].lstrip().startswith("#"):
254
+ break # the next top-level key: services ended
255
+ if start is None or indent is None:
256
+ raise UpdateError(f"no service {service} in it")
257
+ for i in range(start + 1, len(lines)):
258
+ line = lines[i]
259
+ if line.strip() and not line.lstrip().startswith("#") and len(line) - len(line.lstrip()) <= indent:
260
+ break # the next service
261
+ m = re.match(r"(\s+image:\s*)(['\"]?)([^'\"#\s]+)\2(\s*(#.*)?)$", line.rstrip("\n"))
262
+ if m:
263
+ if "${" in line:
264
+ raise UpdateError(f"the image comes from a variable ({m.group(3)}): set it to {image}")
265
+ newline = "\n" if line.endswith("\n") else ""
266
+ lines[i] = f"{m.group(1)}{m.group(2)}{image}{m.group(2)}{m.group(4)}{newline}"
267
+ return "".join(lines), m.group(3)
268
+ raise UpdateError(f"service {service} has no image: line (it may be built from a Dockerfile)")
269
+
270
+
271
+ def run_args(info: dict, image_info: dict, image: str) -> list[str]:
272
+ """`docker run` arguments that recreate a container with another image and the same settings."""
273
+ config, host = info.get("Config") or {}, info.get("HostConfig") or {}
274
+ base = image_info.get("Config") or {}
275
+ args = ["docker", "run", "-d", "--name", info["Name"].lstrip("/")]
276
+ restart = host.get("RestartPolicy") or {}
277
+ if restart.get("Name") not in (None, "", "no"):
278
+ retries = restart.get("MaximumRetryCount") or 0
279
+ args += ["--restart", restart["Name"] + (f":{retries}" if restart["Name"] == "on-failure" and retries else "")]
280
+ for port, bindings in (host.get("PortBindings") or {}).items():
281
+ target = port.removesuffix("/tcp")
282
+ for b in bindings or []:
283
+ ip = b.get("HostIp") or ""
284
+ args += ["-p", f"{ip + ':' if ip else ''}{b.get('HostPort', '')}:{target}"]
285
+ for m in info.get("Mounts") or []:
286
+ source = m.get("Name") if m.get("Type") == "volume" else m.get("Source")
287
+ if source and m.get("Type") in ("volume", "bind"):
288
+ args += ["-v", f"{source}:{m['Destination']}" + ("" if m.get("RW", True) else ":ro")]
289
+ default_env = set(base.get("Env") or [])
290
+ for entry in config.get("Env") or []:
291
+ if entry not in default_env: # only what was set for this container; the new image brings its own PATH
292
+ args += ["-e", entry]
293
+ for request in host.get("DeviceRequests") or []:
294
+ if ["gpu"] in (request.get("Capabilities") or []):
295
+ ids = request.get("DeviceIDs") or []
296
+ count = request.get("Count") or 0
297
+ args += ["--gpus", f"device={','.join(ids)}" if ids else ("all" if count == -1 else str(count))]
298
+ for device in host.get("Devices") or []:
299
+ args += ["--device", f"{device['PathOnHost']}:{device['PathInContainer']}"]
300
+ if host.get("Runtime") not in (None, "", "runc"):
301
+ args += ["--runtime", host["Runtime"]]
302
+ network = host.get("NetworkMode") or "default"
303
+ if network not in ("default", "bridge") and not network.startswith("container:"):
304
+ args += ["--network", network]
305
+ endpoint = ((info.get("NetworkSettings") or {}).get("Networks") or {}).get(network) or {}
306
+ name, short_id = info["Name"].lstrip("/"), info["Id"][:12]
307
+ for alias in endpoint.get("Aliases") or []:
308
+ if alias not in (name, short_id):
309
+ args += ["--network-alias", alias]
310
+ for host_entry in host.get("ExtraHosts") or []:
311
+ args += ["--add-host", host_entry]
312
+ base_labels = base.get("Labels") or {}
313
+ for key, value in (config.get("Labels") or {}).items():
314
+ if base_labels.get(key) != value:
315
+ args += ["--label", f"{key}={value}"]
316
+ entrypoint, cmd = config.get("Entrypoint") or [], config.get("Cmd") or []
317
+ if entrypoint and entrypoint != (base.get("Entrypoint") or []):
318
+ args += ["--entrypoint", entrypoint[0]]
319
+ cmd = [*entrypoint[1:], *cmd]
320
+ elif cmd == (base.get("Cmd") or []):
321
+ cmd = []
322
+ return [*args, image, *cmd]
323
+
324
+
325
+ def wait_for_version(host: str, wanted: str, timeout: float = START_TIMEOUT) -> bool:
326
+ from lcode.ollama import Ollama, OllamaError
327
+
328
+ deadline = time.monotonic() + timeout
329
+ while time.monotonic() < deadline:
330
+ try:
331
+ if Ollama(host).version() == wanted.removesuffix("-rocm"):
332
+ return True
333
+ except OllamaError:
334
+ pass
335
+ time.sleep(2)
336
+ return False
337
+
338
+
339
+ def recreate(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
340
+ """Replace a plain container with one on `image`; put the old one back if that fails."""
341
+ image_info = json.loads(run(["docker", "image", "inspect", container.info["Image"]]).stdout or "[{}]")[0]
342
+ args = run_args(container.info, image_info, image)
343
+ backup = f"{container.name}-before-lcode-update"
344
+ for step in (["docker", "stop", container.name], ["docker", "rename", container.name, backup]):
345
+ r = run(step)
346
+ if r.returncode != 0:
347
+ raise UpdateError(f"{' '.join(step[:2])} failed: {(r.stderr or r.stdout).strip()}")
348
+ say(f"$ {' '.join(args)}")
349
+ started = run(args)
350
+ if started.returncode == 0 and wait_for_version(host, image.rsplit(":", 1)[1]):
351
+ run(["docker", "rm", backup])
352
+ return
353
+ problem = (started.stderr or started.stdout).strip() or "it didn't answer with the new version"
354
+ run(["docker", "rm", "-f", container.name])
355
+ run(["docker", "rename", backup, container.name])
356
+ run(["docker", "start", container.name])
357
+ raise UpdateError(f"the new container didn't start ({problem[:300]}); the old one is running again")
358
+
359
+
360
+ def recreate_with_compose(container: Container, image: str, host: str, say: Callable[[str], None]) -> None:
361
+ command, project_dir = compose_up(container.compose or {})
362
+ say(f"$ {' '.join(command)}")
363
+ if stream(command, cwd=project_dir) != 0:
364
+ raise UpdateError("docker compose up failed")
365
+ if not wait_for_version(host, image.rsplit(":", 1)[1]):
366
+ raise UpdateError(f"Ollama didn't answer with version {image.rsplit(':', 1)[1]} after the restart")
367
+
368
+
369
+ def compose_up(compose: dict) -> tuple[list[str], Path]:
370
+ """`docker compose up` for just the Ollama service of its project, and where to run it."""
371
+ files = [Path(f) for f in compose["files"]]
372
+ project_dir = Path(compose["workdir"]) if compose.get("workdir") else files[0].parent
373
+ command = ["docker", "compose", "-p", compose["project"], "--project-directory", str(project_dir)]
374
+ for f in files:
375
+ command += ["-f", str(f)]
376
+ return [*command, "up", "-d", "--no-deps", compose["service"]], project_dir
377
+
378
+
379
+ def compose_change(container: Container, image: str) -> tuple[Path, str, str]:
380
+ """Which compose file defines the service's image, and its new text."""
381
+ compose = container.compose or {}
382
+ problems = []
383
+ for path in reversed([Path(f) for f in compose.get("files") or []]): # later files override earlier ones
384
+ try:
385
+ text, old = set_compose_image(path.read_text(), compose["service"], image)
386
+ return path, text, old
387
+ except (OSError, UpdateError) as e:
388
+ problems.append(f"{path}: {e}")
389
+ raise UpdateError("couldn't find the image to change: " + "; ".join(problems))
390
+
391
+
392
+ # ----------------------------------------------------------------------------- lcode update
393
+
394
+
395
+ def command(console, what: str = "", check: bool = False, yes: bool = False) -> int:
396
+ """Update lcode and Ollama (or `what`: "lcode" or "ollama"). Returns the exit code."""
397
+ from rich.prompt import Confirm
398
+
399
+ def ask(question: str) -> bool:
400
+ if yes:
401
+ return True
402
+ if not sys.stdin.isatty():
403
+ console.print("[dim]Not asking without a terminal: run lcode update -y to go ahead.[/]")
404
+ return False
405
+ return Confirm.ask(question, default=True, console=console)
406
+
407
+ failed = False
408
+ for part, step in (("lcode", update_lcode), ("ollama", update_ollama)):
409
+ if what and what != part:
410
+ continue
411
+ try:
412
+ step(console, check, ask)
413
+ except UpdateError as e:
414
+ console.print(f"[red]✗ {escape(str(e))}[/]")
415
+ failed = True
416
+ console.print()
417
+ return 1 if failed else 0
418
+
419
+
420
+ def escape(text: str) -> str:
421
+ from rich.markup import escape as rich_escape
422
+
423
+ return rich_escape(text)
424
+
425
+
426
+ def update_lcode(console, check: bool, ask: Callable[[str], bool]) -> None:
427
+ install = lcode_install()
428
+ if install.kind == "checkout" and install.path is not None:
429
+ state = checkout_state(install.path)
430
+ where = f"lcode {__version__} · {install.describe()}, branch {state.branch}"
431
+ if not state.upstream:
432
+ console.print(f"{where}: no upstream branch to update from.")
433
+ elif not state.behind:
434
+ console.print(f"[green]✓[/] {where}: up to date with {state.upstream}.")
435
+ elif check:
436
+ console.print(f"{where}: {state.behind} new commit(s) on {state.upstream}.")
437
+ elif state.dirty or state.branch not in ("main", "master"):
438
+ why = "has uncommitted changes" if state.dirty else f"is on branch {state.branch}"
439
+ console.print(
440
+ f"{where}: {state.behind} new commit(s) on {state.upstream}, but the checkout {why}. Update it yourself with git pull."
441
+ )
442
+ elif ask(f"Pull {state.behind} new commit(s) into {install.path}?"):
443
+ receipt = (Path(sys.prefix) / "uv-receipt.toml").exists()
444
+ console.print(f"[green]✓[/] lcode updated: {update_checkout(install.path, receipt)}.")
445
+ return
446
+ latest = latest_lcode()
447
+ if version_key(latest) <= version_key(__version__):
448
+ console.print(f"[green]✓[/] lcode {__version__} is the newest version ({install.describe()}).")
449
+ return
450
+ if install.command is None:
451
+ console.print(
452
+ f"lcode {__version__} → {latest} is out. You run lcode with uvx: use uvx --refresh --from lcode-cli lcode"
453
+ )
454
+ return
455
+ shown = " ".join(install.command)
456
+ if check:
457
+ console.print(f"lcode {__version__} → {latest} is out ({install.describe()}): {shown}")
458
+ return
459
+ if not ask(f"Update lcode {__version__} → {latest} with `{shown}`?"):
460
+ return
461
+ if stream(install.command) != 0:
462
+ raise UpdateError(f"{shown} failed")
463
+ console.print("[green]✓[/] lcode updated; the new version runs from the next `lcode`.")
464
+ if install.kind == "brew":
465
+ console.print(
466
+ "[dim]Homebrew's formula follows a release by up to a day: if it's still the old version, try again later.[/]"
467
+ )
468
+
469
+
470
+ def update_ollama(console, check: bool, ask: Callable[[str], bool]) -> None:
471
+ from lcode import config
472
+ from lcode.hardware import detect
473
+ from lcode.ollama import Ollama, OllamaError
474
+
475
+ cfg = config.load()
476
+ if cfg["backend"] != "ollama":
477
+ console.print(f"lcode uses another model server (backend = {cfg['backend']}): update it there.")
478
+ return
479
+ host = cfg["ollama_host"]
480
+ try:
481
+ current = Ollama(host).version()
482
+ except OllamaError:
483
+ current = ""
484
+ container = find_container(host) if docker_available() else None
485
+ if container is None:
486
+ if not current:
487
+ raise UpdateError(f"Ollama doesn't answer at {host}: start it, then run lcode update again")
488
+ latest = latest_ollama()
489
+ local = urlsplit(host).hostname in ("localhost", "127.0.0.1", "::1", "0.0.0.0", None)
490
+ if version_key(latest) <= version_key(current):
491
+ console.print(f"[green]✓[/] Ollama {current} is the newest version.")
492
+ elif not local:
493
+ console.print(f"Ollama {current} → {latest} is out. It runs on {host}: update it on that machine.")
494
+ else:
495
+ hint = NATIVE.get(detect().os, NATIVE["linux"])
496
+ console.print(
497
+ f"Ollama {current} → {latest} is out. Ollama isn't in a Docker container here, so update it with:\n {hint}"
498
+ )
499
+ return
500
+ latest = latest_ollama(image_flavor(container.image))
501
+ image = f"{IMAGE}:{latest}"
502
+ compose = container.compose
503
+ where = f"container {container.name}, image {container.image}" + (
504
+ f", Docker Compose project {compose['project']}" if compose else ""
505
+ )
506
+ console.print(f"Ollama {current or '?'} · {where}. Newest on Docker Hub: {latest}.")
507
+ if container.image == image and current == latest.removesuffix("-rocm"):
508
+ console.print(f"[green]✓[/] Ollama is up to date, on {image}.")
509
+ return
510
+ if current == latest.removesuffix("-rocm"):
511
+ console.print(
512
+ f"[dim]It already runs {current}, but as {container.image}: pinning it to {image} shows the version in docker ps.[/]"
513
+ )
514
+ change = compose_change(container, image) if compose else None
515
+ plan = [f"docker pull {image}"]
516
+ if change:
517
+ path, _, old = change
518
+ plan.append(f"in {path}, service {compose['service']}: image {old} → {image}") # type: ignore[index]
519
+ plan.append(f"docker compose up -d --no-deps {compose['service']}") # type: ignore[index]
520
+ else:
521
+ plan.append(
522
+ f"recreate container {container.name} on {image}, with the same name, ports, volumes, environment, GPUs, network and restart policy (the old one is kept until the new one answers)"
523
+ )
524
+ console.print("\n".join(f" {i}. {escape(step)}" for i, step in enumerate(plan, 1)))
525
+ if check:
526
+ return
527
+ console.print("[dim]Ollama restarts: requests running now stop, and models load again on their next request.[/]")
528
+ if not ask("Update Ollama?"):
529
+ return
530
+ if stream(["docker", "pull", image]) != 0:
531
+ raise UpdateError(f"docker pull {image} failed")
532
+
533
+ def say(text: str) -> None:
534
+ console.print(f"[dim]{escape(text)}[/]")
535
+
536
+ if change:
537
+ path, text, _ = change
538
+ original = path.read_text()
539
+ path.write_text(text)
540
+ try:
541
+ recreate_with_compose(container, image, host, say)
542
+ except UpdateError:
543
+ path.write_text(original)
544
+ say(f"put {path} back the way it was")
545
+ command, workdir = compose_up(compose) # type: ignore[arg-type]
546
+ stream(command, cwd=workdir)
547
+ raise
548
+ else:
549
+ recreate(container, image, host, say)
550
+ console.print(f"[green]✓[/] Ollama {current or '?'} → {latest.removesuffix('-rocm')}, running as {image}.")
551
+ old_image = container.info.get("Image", "")
552
+ if old_image and not run(["docker", "ps", "-aq", "--filter", f"ancestor={old_image}"]).stdout.strip():
553
+ size = run(["docker", "image", "inspect", old_image, "--format", "{{.Size}}"]).stdout.strip()
554
+ gb = f" ({int(size) / 1e9:.1f} GB)" if size.isdigit() else ""
555
+ console.print(
556
+ f"[dim]The previous image is still on disk{gb}. To remove it: docker image rm {old_image[7:19]}[/]"
557
+ )
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.20.0
3
+ Version: 0.21.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/
@@ -1,4 +1,4 @@
1
- lcode/__init__.py,sha256=4WFGTgYgTZwsc2yTfp-j5FUCi0s6YN2WTQKRYUO9oeU,473
1
+ lcode/__init__.py,sha256=CcNLwxNc-qPLNMDrdZMe5h6rRYH_9ISij5uyXwc5PBA,473
2
2
  lcode/__main__.py,sha256=s5N5pryX2FAu93GGZ3FxUrIXHWGzxz66lKUoaV_h8Ws,35
3
3
  lcode/acp.py,sha256=kRD_uuDDdGXt7xwvXZ8OqePK1HprEV76wWAWkV_gcak,28987
4
4
  lcode/action.py,sha256=1r3abjheYFYQ-EUArvotls8pLmE3YjNMGHAy_iF0Z70,16198
@@ -9,7 +9,7 @@ lcode/bench.py,sha256=XVdk0hDxmRcjgROOCgwwOsExcUhIxgh0B2vnxtGnA3o,40742
9
9
  lcode/bench_backend.py,sha256=X2fo-zRX92Xp3KMxSsFlWioc2vRupBTtowj5kLME8mw,14287
10
10
  lcode/catalog.py,sha256=pZ1azX9OH2r4JE2c6E5OfwywpgS39-W2oXzoCHJhmtM,4141
11
11
  lcode/checkpoints.py,sha256=eqPtmPe44-aweWqc2H21ROogwzIt3NpvcUobsaCHoGY,18354
12
- lcode/cli.py,sha256=zo2QTHrdxVaJwIjCEOsfR99byPqeCkKHE5p_Xh8kfb8,39471
12
+ lcode/cli.py,sha256=ue5wsoFxraXXVu7X32S4tbKPLKYhgeI6zpK2i5nXXlU,40180
13
13
  lcode/codesearch.py,sha256=Io8leN1nb_Y-Dvlm9xaGtwUWYXX-ooswuznJV6dGU1s,10766
14
14
  lcode/config.py,sha256=eDEvOAA1plsSjvihzHEwGjNVgvcz3Pb-AR5DY5JmXnM,10255
15
15
  lcode/context.py,sha256=qJymLE3eYeaF6qVOzlUwDURqlky__8cskl_6vXeU3AA,10701
@@ -34,6 +34,7 @@ lcode/sandbox.py,sha256=u_pkrVRXhCGhh-wFDPwuZ3jGG7UeXxXRAeBNsm37ZvE,8608
34
34
  lcode/sessions.py,sha256=wOR-J0EHgJxLSPboKPHnvBcp5VTUYTj3tZWgpsa6-K4,4013
35
35
  lcode/subagents.py,sha256=rD7lnIioodirh8eVnXvRWdZx79J_rscPLssD2Gt7KIo,28914
36
36
  lcode/tools.py,sha256=pOdA4ihQHSs8yWUzfxhiLw3GajF5Dr1edxVX0Akr6CU,47257
37
+ lcode/update.py,sha256=i92bnW252VneYh0vD6djxmXsDOa5MXP20bjlLZUue_c,25236
37
38
  lcode/vision.py,sha256=w33QPmdGwP33wM4mvmAH4amFDZ1xzeXhEsSLJ-STSHY,4243
38
39
  lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
39
40
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
@@ -51,8 +52,8 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
51
52
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
52
53
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
53
54
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
54
- lcode_cli-0.20.0.dist-info/METADATA,sha256=x59SrZvhlqu_9PZpirJgYDSKny6D_yubs_cE9yFzDuo,12527
55
- lcode_cli-0.20.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
56
- lcode_cli-0.20.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
57
- lcode_cli-0.20.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
58
- lcode_cli-0.20.0.dist-info/RECORD,,
55
+ lcode_cli-0.21.0.dist-info/METADATA,sha256=5G93iKQq-92dbzpKJxamXKr9giXtXiVm0AsqhcJf4NU,12527
56
+ lcode_cli-0.21.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
57
+ lcode_cli-0.21.0.dist-info/entry_points.txt,sha256=pMz2Byj2QRyt-ZZQYZvmWUtmKPCeE7f8iCvu-sjZPIo,133
58
+ lcode_cli-0.21.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
59
+ lcode_cli-0.21.0.dist-info/RECORD,,