lcode-cli 0.20.0__py3-none-any.whl → 0.22.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- lcode/__init__.py +1 -1
- lcode/cli.py +17 -1
- lcode/mcp/catalog.toml +21 -0
- lcode/servers/xai.py +289 -0
- lcode/update.py +557 -0
- {lcode_cli-0.20.0.dist-info → lcode_cli-0.22.0.dist-info}/METADATA +3 -2
- {lcode_cli-0.20.0.dist-info → lcode_cli-0.22.0.dist-info}/RECORD +10 -8
- {lcode_cli-0.20.0.dist-info → lcode_cli-0.22.0.dist-info}/entry_points.txt +1 -0
- {lcode_cli-0.20.0.dist-info → lcode_cli-0.22.0.dist-info}/WHEEL +0 -0
- {lcode_cli-0.20.0.dist-info → lcode_cli-0.22.0.dist-info}/licenses/LICENSE +0 -0
lcode/__init__.py
CHANGED
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/mcp/catalog.toml
CHANGED
|
@@ -281,3 +281,24 @@ secret = true
|
|
|
281
281
|
var = "VALOHAI_HOST"
|
|
282
282
|
prompt = "Valohai address (Enter for app.valohai.com; for a self-hosted installation, its URL)"
|
|
283
283
|
optional = true
|
|
284
|
+
|
|
285
|
+
[xai]
|
|
286
|
+
name = "xAI Grok Imagine"
|
|
287
|
+
description = "Generate and edit images, and generate videos and speech, with xAI's Grok Imagine and voice API (paid, in the cloud)"
|
|
288
|
+
homepage = "https://nasser1941.github.io/lcode/mcp/#xai"
|
|
289
|
+
requires = ["uvx"]
|
|
290
|
+
setup = """Uses xAI's API: your prompts, and images you edit or animate, go to xAI, and every call is billed to your xAI
|
|
291
|
+
account (an image $0.02-0.05, video $0.02-0.08 a second, speech per character). Create an API key at https://console.x.ai.
|
|
292
|
+
Files are saved in generated/ in the project. lcode asks before each call; to stop asking for some tools, list them
|
|
293
|
+
under "allow" for this server in mcp.json. xAI has no music generation."""
|
|
294
|
+
|
|
295
|
+
[xai.server]
|
|
296
|
+
command = "uvx"
|
|
297
|
+
args = ["--isolated", "--python", "3.12", "--from", "lcode-cli", "lcode-mcp-xai"]
|
|
298
|
+
env = { XAI_API_KEY = "${XAI_API_KEY}" }
|
|
299
|
+
timeout = 900 # a video takes minutes
|
|
300
|
+
|
|
301
|
+
[[xai.inputs]]
|
|
302
|
+
var = "XAI_API_KEY"
|
|
303
|
+
prompt = "xAI API key (create one at https://console.x.ai)"
|
|
304
|
+
secret = true
|
lcode/servers/xai.py
ADDED
|
@@ -0,0 +1,289 @@
|
|
|
1
|
+
"""An MCP server for xAI's Grok Imagine and voice APIs: images, image edits, videos and speech.
|
|
2
|
+
|
|
3
|
+
xAI has an HTTP API but no MCP server, so lcode ships this one. It needs an API key (XAI_API_KEY,
|
|
4
|
+
from https://console.x.ai) and saves what it makes in the project, in `generated/` unless a path is
|
|
5
|
+
given. The prompts, and any image it edits or animates, go to xAI, which bills per image, per
|
|
6
|
+
second of video and per character of speech, and applies its own usage policies.
|
|
7
|
+
|
|
8
|
+
Run: lcode-mcp-xai (lcode mcp add xai sets it up)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
import argparse
|
|
14
|
+
import base64
|
|
15
|
+
import mimetypes
|
|
16
|
+
import os
|
|
17
|
+
import re
|
|
18
|
+
import time
|
|
19
|
+
from pathlib import Path
|
|
20
|
+
|
|
21
|
+
import requests
|
|
22
|
+
|
|
23
|
+
from lcode import __version__
|
|
24
|
+
from lcode.mcp.server import Server, ToolFailure
|
|
25
|
+
|
|
26
|
+
API = "https://api.x.ai/v1"
|
|
27
|
+
TIMEOUT = 120
|
|
28
|
+
IMAGE_MODELS = ["grok-imagine-image-2.0", "grok-imagine-image-quality", "grok-imagine-image"]
|
|
29
|
+
VIDEO_MODELS = ["grok-imagine-video-1.5", "grok-imagine-video-1.5-lite", "grok-imagine-video"]
|
|
30
|
+
ASPECT_RATIOS = ["auto", "1:1", "16:9", "9:16", "4:3", "3:4", "3:2", "2:3", "2:1", "1:2", "21:9"]
|
|
31
|
+
VOICES = ["eve", "ara", "leo", "rex", "sal", "luna", "iris", "orion", "helix", "zagan", "carina", "altair", "zenith",
|
|
32
|
+
"perseus", "helios", "lux", "kepler", "rigel", "cosmo", "celeste", "ursa", "sirius", "lumen", "castor",
|
|
33
|
+
"naksh", "atlas"] # fmt: skip
|
|
34
|
+
AUDIO_FORMATS = {"mp3": ".mp3", "wav": ".wav"}
|
|
35
|
+
VIDEO_WAIT = 600 # seconds generate_video waits before handing back the request id
|
|
36
|
+
POLL = 5
|
|
37
|
+
|
|
38
|
+
|
|
39
|
+
class XAI:
|
|
40
|
+
def __init__(self, key: str, base: str = API, output: Path | None = None):
|
|
41
|
+
self.base = base.rstrip("/")
|
|
42
|
+
self.output = output or Path("generated")
|
|
43
|
+
self.session = requests.Session()
|
|
44
|
+
self.session.headers.update({"Authorization": f"Bearer {key}", "Content-Type": "application/json"})
|
|
45
|
+
|
|
46
|
+
def request(self, method: str, path: str, **kwargs) -> requests.Response:
|
|
47
|
+
try:
|
|
48
|
+
r = self.session.request(method, f"{self.base}{path}", timeout=TIMEOUT, **kwargs)
|
|
49
|
+
except requests.RequestException as e:
|
|
50
|
+
raise ToolFailure(f"can't reach xAI: {e}") from e
|
|
51
|
+
if r.status_code in (401, 403):
|
|
52
|
+
raise ToolFailure(
|
|
53
|
+
f"xAI refused the request ({r.status_code}): {_error(r)}. Check XAI_API_KEY (console.x.ai)."
|
|
54
|
+
)
|
|
55
|
+
if r.status_code == 429:
|
|
56
|
+
raise ToolFailure(f"xAI's rate limit or credit limit was reached: {_error(r)}")
|
|
57
|
+
if r.status_code >= 400:
|
|
58
|
+
raise ToolFailure(f"xAI error {r.status_code}: {_error(r)}")
|
|
59
|
+
return r
|
|
60
|
+
|
|
61
|
+
def post(self, path: str, body: dict) -> dict:
|
|
62
|
+
return self.request("POST", path, json=body).json()
|
|
63
|
+
|
|
64
|
+
def download(self, url: str) -> bytes:
|
|
65
|
+
try:
|
|
66
|
+
r = requests.get(url, timeout=TIMEOUT)
|
|
67
|
+
r.raise_for_status()
|
|
68
|
+
except requests.RequestException as e:
|
|
69
|
+
raise ToolFailure(f"couldn't download the result from xAI: {e}") from e
|
|
70
|
+
return r.content
|
|
71
|
+
|
|
72
|
+
def target(self, path: str, stem: str, suffix: str) -> Path:
|
|
73
|
+
"""Where to save: the given path, or a new file in the output folder."""
|
|
74
|
+
if path:
|
|
75
|
+
target = Path(path).expanduser()
|
|
76
|
+
if not target.suffix:
|
|
77
|
+
target = target.with_suffix(suffix)
|
|
78
|
+
else:
|
|
79
|
+
name = re.sub(r"[^a-z0-9]+", "-", stem.lower()).strip("-")[:40] or "xai"
|
|
80
|
+
target = self.output / f"{name}-{time.strftime('%Y%m%d-%H%M%S')}{suffix}"
|
|
81
|
+
target.parent.mkdir(parents=True, exist_ok=True)
|
|
82
|
+
n, unique = 2, target
|
|
83
|
+
while unique.exists():
|
|
84
|
+
unique = target.with_name(f"{target.stem}-{n}{target.suffix}")
|
|
85
|
+
n += 1
|
|
86
|
+
return unique
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _error(r: requests.Response) -> str:
|
|
90
|
+
try:
|
|
91
|
+
data = r.json()
|
|
92
|
+
except ValueError:
|
|
93
|
+
return r.text[:300]
|
|
94
|
+
error = data.get("error") if isinstance(data, dict) else None
|
|
95
|
+
if isinstance(error, dict):
|
|
96
|
+
return str(error.get("message") or error)[:300]
|
|
97
|
+
return str(error or data)[:300]
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
def data_url(path: str) -> str:
|
|
101
|
+
"""A local image as a data: URL, for edits and image-to-video."""
|
|
102
|
+
file = Path(path).expanduser()
|
|
103
|
+
if not file.is_file():
|
|
104
|
+
raise ToolFailure(f"{path} doesn't exist")
|
|
105
|
+
mime = mimetypes.guess_type(file.name)[0] or "image/png"
|
|
106
|
+
if mime not in ("image/png", "image/jpeg", "image/webp"):
|
|
107
|
+
raise ToolFailure(f"{path} isn't a PNG, JPEG or WebP image")
|
|
108
|
+
return f"data:{mime};base64,{base64.b64encode(file.read_bytes()).decode()}"
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def image_suffix(data: bytes) -> str:
|
|
112
|
+
if data[:3] == b"\xff\xd8\xff":
|
|
113
|
+
return ".jpg"
|
|
114
|
+
if data[:4] == b"RIFF" and data[8:12] == b"WEBP":
|
|
115
|
+
return ".webp"
|
|
116
|
+
return ".png"
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def save_images(api: XAI, reply: dict, prompt: str, path: str) -> list[Path]:
|
|
120
|
+
saved = []
|
|
121
|
+
items = reply.get("data") or []
|
|
122
|
+
if not items:
|
|
123
|
+
raise ToolFailure(f"xAI returned no image: {str(reply)[:300]}")
|
|
124
|
+
for i, item in enumerate(items):
|
|
125
|
+
data = base64.b64decode(item["b64_json"]) if item.get("b64_json") else api.download(item["url"])
|
|
126
|
+
many = path and len(items) > 1
|
|
127
|
+
target = api.target(f"{Path(path).with_suffix('')}-{i + 1}" if many else path, prompt, image_suffix(data))
|
|
128
|
+
target.write_bytes(data)
|
|
129
|
+
saved.append(target)
|
|
130
|
+
return saved
|
|
131
|
+
|
|
132
|
+
|
|
133
|
+
def build(api: XAI) -> Server:
|
|
134
|
+
server = Server(
|
|
135
|
+
"xai",
|
|
136
|
+
__version__,
|
|
137
|
+
"xAI's Grok Imagine and voice: generate and edit images, generate videos (from text or a start image) "
|
|
138
|
+
"and speech. Results are saved as files in the project; each call costs money on the user's xAI account. "
|
|
139
|
+
"xAI has no music generation.",
|
|
140
|
+
)
|
|
141
|
+
path_arg = {"type": "string", "description": "Where to save it (default: a new file in generated/)"}
|
|
142
|
+
|
|
143
|
+
@server.tool(
|
|
144
|
+
"generate_image",
|
|
145
|
+
"Generate images from a description with Grok Imagine and save them. Costs $0.02–0.05 per image.",
|
|
146
|
+
{
|
|
147
|
+
"prompt": {"type": "string", "description": "What the image shows, in detail"},
|
|
148
|
+
"n": {"type": "integer", "description": "How many images, 1–10 (default 1)"},
|
|
149
|
+
"aspect_ratio": {"type": "string", "enum": ASPECT_RATIOS, "description": "Default auto"},
|
|
150
|
+
"resolution": {"type": "string", "enum": ["1k", "2k"], "description": "Default 1k"},
|
|
151
|
+
"model": {"type": "string", "enum": IMAGE_MODELS, "description": f"Default {IMAGE_MODELS[0]}"},
|
|
152
|
+
"path": path_arg,
|
|
153
|
+
},
|
|
154
|
+
["prompt"],
|
|
155
|
+
)
|
|
156
|
+
def generate_image(prompt: str, n: int = 1, aspect_ratio: str = "auto", resolution: str = "1k",
|
|
157
|
+
model: str = IMAGE_MODELS[0], path: str = "") -> str: # fmt: skip
|
|
158
|
+
body = {"model": model, "prompt": prompt, "n": max(1, min(10, int(n))), "response_format": "b64_json"}
|
|
159
|
+
if aspect_ratio and aspect_ratio != "auto":
|
|
160
|
+
body["aspect_ratio"] = aspect_ratio
|
|
161
|
+
if resolution and resolution != "1k":
|
|
162
|
+
body["resolution"] = resolution
|
|
163
|
+
saved = save_images(api, api.post("/images/generations", body), prompt, path)
|
|
164
|
+
return f"Saved {len(saved)} image(s) from {model}:\n" + "\n".join(f"- {p}" for p in saved)
|
|
165
|
+
|
|
166
|
+
@server.tool(
|
|
167
|
+
"edit_image",
|
|
168
|
+
"Change an existing image (a local file) as described, with Grok Imagine, and save the result.",
|
|
169
|
+
{
|
|
170
|
+
"image": {"type": "string", "description": "Path of the image to edit (PNG, JPEG or WebP)"},
|
|
171
|
+
"prompt": {"type": "string", "description": "What to change"},
|
|
172
|
+
"model": {"type": "string", "enum": IMAGE_MODELS, "description": f"Default {IMAGE_MODELS[0]}"},
|
|
173
|
+
"path": path_arg,
|
|
174
|
+
},
|
|
175
|
+
["image", "prompt"],
|
|
176
|
+
)
|
|
177
|
+
def edit_image(image: str, prompt: str, model: str = IMAGE_MODELS[0], path: str = "") -> str:
|
|
178
|
+
body = {"model": model, "prompt": prompt, "image": {"url": data_url(image), "type": "image_url"},
|
|
179
|
+
"response_format": "b64_json"} # fmt: skip
|
|
180
|
+
saved = save_images(api, api.post("/images/edits", body), prompt, path)
|
|
181
|
+
return f"Saved the edited image: {saved[0]}"
|
|
182
|
+
|
|
183
|
+
def finish_video(request_id: str, prompt: str, path: str, wait: float) -> str:
|
|
184
|
+
deadline = time.monotonic() + wait
|
|
185
|
+
while True:
|
|
186
|
+
state = api.request("GET", f"/videos/{request_id}").json()
|
|
187
|
+
status = state.get("status")
|
|
188
|
+
if status == "done":
|
|
189
|
+
video = state.get("video") or {}
|
|
190
|
+
target = api.target(path, prompt or request_id, ".mp4")
|
|
191
|
+
target.write_bytes(api.download(video["url"]))
|
|
192
|
+
return f"Saved the video ({video.get('duration', '?')} s, {state.get('model', '')}): {target}"
|
|
193
|
+
if status in ("failed", "expired"):
|
|
194
|
+
raise ToolFailure(f"xAI couldn't make the video ({status}): {state.get('error') or state}")
|
|
195
|
+
if time.monotonic() > deadline:
|
|
196
|
+
return (
|
|
197
|
+
f"The video isn't ready yet (request {request_id}). Check later with "
|
|
198
|
+
f'video_status(request_id="{request_id}"), which downloads it when it\'s done.'
|
|
199
|
+
)
|
|
200
|
+
time.sleep(POLL)
|
|
201
|
+
|
|
202
|
+
@server.tool(
|
|
203
|
+
"generate_video",
|
|
204
|
+
"Generate a video with Grok Imagine, from a description and optionally a start image, and save it as "
|
|
205
|
+
"MP4. Takes a minute or more. Costs $0.02–0.08 per second of video.",
|
|
206
|
+
{
|
|
207
|
+
"prompt": {"type": "string", "description": "What happens in the video"},
|
|
208
|
+
"image": {"type": "string", "description": "A local image to start from (optional)"},
|
|
209
|
+
"duration": {"type": "integer", "description": "Seconds, 1–15 (default 8)"},
|
|
210
|
+
"aspect_ratio": {
|
|
211
|
+
"type": "string",
|
|
212
|
+
"enum": [r for r in ASPECT_RATIOS if r not in ("auto", "2:1", "1:2", "21:9")],
|
|
213
|
+
},
|
|
214
|
+
"resolution": {"type": "string", "enum": ["480p", "720p", "1080p"], "description": "Default 720p"},
|
|
215
|
+
"model": {"type": "string", "enum": VIDEO_MODELS, "description": f"Default {VIDEO_MODELS[0]}"},
|
|
216
|
+
"path": path_arg,
|
|
217
|
+
},
|
|
218
|
+
["prompt"],
|
|
219
|
+
)
|
|
220
|
+
def generate_video(prompt: str, image: str = "", duration: int = 8, aspect_ratio: str = "16:9",
|
|
221
|
+
resolution: str = "720p", model: str = VIDEO_MODELS[0], path: str = "") -> str: # fmt: skip
|
|
222
|
+
body: dict = {"model": model, "prompt": prompt, "duration": max(1, min(15, int(duration))),
|
|
223
|
+
"aspect_ratio": aspect_ratio, "resolution": resolution} # fmt: skip
|
|
224
|
+
if image:
|
|
225
|
+
body["image"] = {"url": data_url(image)}
|
|
226
|
+
request_id = api.post("/videos/generations", body).get("request_id")
|
|
227
|
+
if not request_id:
|
|
228
|
+
raise ToolFailure("xAI didn't start the video")
|
|
229
|
+
return finish_video(str(request_id), prompt, path, VIDEO_WAIT)
|
|
230
|
+
|
|
231
|
+
@server.tool(
|
|
232
|
+
"video_status",
|
|
233
|
+
"Check a video that generate_video started and that wasn't ready yet; saves it when it's done.",
|
|
234
|
+
{"request_id": {"type": "string"}, "path": path_arg},
|
|
235
|
+
["request_id"],
|
|
236
|
+
)
|
|
237
|
+
def video_status(request_id: str, path: str = "") -> str:
|
|
238
|
+
return finish_video(request_id, "", path, 0)
|
|
239
|
+
|
|
240
|
+
@server.tool(
|
|
241
|
+
"text_to_speech",
|
|
242
|
+
"Turn text into speech with an xAI voice and save it as an audio file. Billed per character.",
|
|
243
|
+
{
|
|
244
|
+
"text": {"type": "string", "description": "What to say (up to 60,000 characters)"},
|
|
245
|
+
"voice": {"type": "string", "enum": VOICES, "description": "Default eve"},
|
|
246
|
+
"language": {"type": "string", "description": "BCP-47 code such as en, de, fr, ja, or auto (default)"},
|
|
247
|
+
"format": {"type": "string", "enum": list(AUDIO_FORMATS), "description": "Default mp3"},
|
|
248
|
+
"speed": {"type": "number", "description": "1.0 is normal"},
|
|
249
|
+
"path": path_arg,
|
|
250
|
+
},
|
|
251
|
+
["text"],
|
|
252
|
+
)
|
|
253
|
+
def text_to_speech(text: str, voice: str = "eve", language: str = "auto", format: str = "mp3",
|
|
254
|
+
speed: float = 1.0, path: str = "") -> str: # fmt: skip
|
|
255
|
+
codec = format if format in AUDIO_FORMATS else "mp3"
|
|
256
|
+
body = {"text": text, "voice_id": voice.lower(), "language": language or "auto",
|
|
257
|
+
"output_format": {"codec": codec}, "speed": float(speed or 1.0)} # fmt: skip
|
|
258
|
+
r = api.request("POST", "/tts", json=body)
|
|
259
|
+
duration = ""
|
|
260
|
+
if "json" in r.headers.get("Content-Type", ""):
|
|
261
|
+
data = r.json()
|
|
262
|
+
audio = base64.b64decode(data.get("audio", ""))
|
|
263
|
+
duration = f", {data['duration']:.1f} s" if isinstance(data.get("duration"), (int, float)) else ""
|
|
264
|
+
else:
|
|
265
|
+
audio = r.content
|
|
266
|
+
if not audio:
|
|
267
|
+
raise ToolFailure("xAI returned no audio")
|
|
268
|
+
target = api.target(path, text[:40], AUDIO_FORMATS[codec])
|
|
269
|
+
target.write_bytes(audio)
|
|
270
|
+
return f"Saved the speech ({voice}{duration}): {target}"
|
|
271
|
+
|
|
272
|
+
return server
|
|
273
|
+
|
|
274
|
+
|
|
275
|
+
def main(argv: list[str] | None = None) -> None:
|
|
276
|
+
parser = argparse.ArgumentParser(
|
|
277
|
+
prog="lcode-mcp-xai", description="MCP server for xAI's image, video and speech APIs (stdio)."
|
|
278
|
+
)
|
|
279
|
+
parser.add_argument("--output", default=os.environ.get("LCODE_XAI_OUTPUT", "generated"),
|
|
280
|
+
help="folder for the files it makes (default: generated/ in the project)") # fmt: skip
|
|
281
|
+
args = parser.parse_args(argv)
|
|
282
|
+
key = os.environ.get("XAI_API_KEY")
|
|
283
|
+
if not key:
|
|
284
|
+
parser.error("set XAI_API_KEY to an xAI API key (https://console.x.ai)")
|
|
285
|
+
build(XAI(key, os.environ.get("XAI_API_BASE", API), Path(args.output))).serve()
|
|
286
|
+
|
|
287
|
+
|
|
288
|
+
if __name__ == "__main__":
|
|
289
|
+
main()
|
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.
|
|
3
|
+
Version: 0.22.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/
|
|
@@ -116,7 +116,8 @@ through [Ollama](https://ollama.com), and when it needs current information it c
|
|
|
116
116
|
(`lcode mcp add atlassian`), image generation with ComfyUI, or any other MCP server. Browser sign-in (OAuth) is built in.
|
|
117
117
|
- **Sees images.** Attach a screenshot or mockup with `@path` and lcode looks at it, using the
|
|
118
118
|
vision part of your model. With ComfyUI (`lcode mcp add comfyui`) it can also generate and edit
|
|
119
|
-
images with local models such as FLUX, SDXL and Qwen-Image
|
|
119
|
+
images with local models such as FLUX, SDXL and Qwen-Image, or, in the cloud, images, videos
|
|
120
|
+
and speech with xAI's Grok Imagine (`lcode mcp add xai`).
|
|
120
121
|
- **Web search when needed.** Looks up the latest versions, docs and error messages with Ollama web
|
|
121
122
|
search, Brave, Tavily or your own SearXNG, and reads pages as clean text.
|
|
122
123
|
- **Private.** The model runs on your machine, lcode never uploads your files and has no telemetry.
|
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
lcode/__init__.py,sha256=
|
|
1
|
+
lcode/__init__.py,sha256=s1wi6lDwnIt_wcDMaRuhH7baVfU3UfYMQ2ejuBf_W9E,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=
|
|
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,12 +34,13 @@ 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
|
|
40
41
|
lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
|
|
41
42
|
lcode/mcp/catalog.py,sha256=I_O10N7cBl0ZW6O7x2kwKn89pvEXUXODUh8FsPXnB-E,3790
|
|
42
|
-
lcode/mcp/catalog.toml,sha256=
|
|
43
|
+
lcode/mcp/catalog.toml,sha256=N8MKyyS_FeCo-RbV3RVZ3ZODro2yk55yqUlymXsWdcQ,12641
|
|
43
44
|
lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
|
|
44
45
|
lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
|
|
45
46
|
lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
|
|
@@ -51,8 +52,9 @@ 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
|
-
|
|
55
|
-
lcode_cli-0.
|
|
56
|
-
lcode_cli-0.
|
|
57
|
-
lcode_cli-0.
|
|
58
|
-
lcode_cli-0.
|
|
55
|
+
lcode/servers/xai.py,sha256=h2emAvHSnkuB9WmJInlynsDhvxJwNVD2AHFX7vqMXNc,13627
|
|
56
|
+
lcode_cli-0.22.0.dist-info/METADATA,sha256=3pd56TbC41-H0Ta-kGkl1jn461kw3OqcIOLp-2DZrJE,12620
|
|
57
|
+
lcode_cli-0.22.0.dist-info/WHEEL,sha256=W3fkpkm7-wf9vBI5Z-7s0eWkeM-spu78I8Neb98DeEg,87
|
|
58
|
+
lcode_cli-0.22.0.dist-info/entry_points.txt,sha256=Hxsn_itbnbgft3U0FwxWdmEt0yoAV2LB-nJDnL9WT20,172
|
|
59
|
+
lcode_cli-0.22.0.dist-info/licenses/LICENSE,sha256=JWjmoL7nNIC1XUa-dZm2hwLUgjUQMFo6k8APb0idNzs,1074
|
|
60
|
+
lcode_cli-0.22.0.dist-info/RECORD,,
|
|
File without changes
|
|
File without changes
|