lcode-cli 0.21.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 CHANGED
@@ -3,7 +3,7 @@
3
3
  From a program: `from lcode import Session` (see lcode.api).
4
4
  """
5
5
 
6
- __version__ = "0.21.0"
6
+ __version__ = "0.22.0"
7
7
 
8
8
  API = ("Session", "Result", "Options", "SetupError")
9
9
 
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()
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.5
2
2
  Name: lcode-cli
3
- Version: 0.21.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=CcNLwxNc-qPLNMDrdZMe5h6rRYH_9ISij5uyXwc5PBA,473
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
@@ -40,7 +40,7 @@ lcode/web.py,sha256=vyxKbSRJTl1Lj3hj7Yd_NwTgbv0Xi6dup2YfUXjqp-Y,14579
40
40
  lcode/mcp/__init__.py,sha256=XujtAmRoXW9S0uq1XPf_CAeCkHh_UvA8FCmoAKP9j1Y,171
41
41
  lcode/mcp/auth.py,sha256=vyH3oolgIBNJLYabr2h5Soui99NTY1QxjQEgtczfF6A,16655
42
42
  lcode/mcp/catalog.py,sha256=I_O10N7cBl0ZW6O7x2kwKn89pvEXUXODUh8FsPXnB-E,3790
43
- lcode/mcp/catalog.toml,sha256=9uIcH8z2hAzaPzRNuVeSZs239uhIjroGNd76SkmK_NA,11662
43
+ lcode/mcp/catalog.toml,sha256=N8MKyyS_FeCo-RbV3RVZ3ZODro2yk55yqUlymXsWdcQ,12641
44
44
  lcode/mcp/client.py,sha256=3Gen7zRvWehngXn_dAMOAdKqFkFkQZIQW96hQcW4N4k,7119
45
45
  lcode/mcp/commands.py,sha256=mnp0G-GW5ZsARi2tkHuT3hl883sBGFUVRAV3AapA5oU,16478
46
46
  lcode/mcp/config.py,sha256=Ej6WX2b_55a84H5WdAwSk9DzERKoq5ifUBQ9uDgSG_o,8780
@@ -52,8 +52,9 @@ lcode/mcp/stdio.py,sha256=kC6KHwzMMDjBr-yCZLmyVKS7PZXU299WpZdYRoX5eRQ,6292
52
52
  lcode/servers/__init__.py,sha256=qrIhxEodEB8cjKEQ4Cicc0GsQhE2OH-9qlVHzr3-tq8,77
53
53
  lcode/servers/encord.py,sha256=WSauawL1UKYZCUAG1oalcKL_An3DVof-JM6xIgFyQSE,12769
54
54
  lcode/servers/valohai.py,sha256=e5xZGIoa4cja4f5i85q1GpL9c7cglUjV8jN2co1CFMY,14430
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,,
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,,
@@ -2,3 +2,4 @@
2
2
  lcode = lcode.cli:main
3
3
  lcode-mcp-encord = lcode.servers.encord:main
4
4
  lcode-mcp-valohai = lcode.servers.valohai:main
5
+ lcode-mcp-xai = lcode.servers.xai:main