grokbuildstreaming 0.1.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.
@@ -0,0 +1,27 @@
1
+ """Stream text from the signed-in Grok Build CLI.
2
+
3
+ The CLI session created by ``grok login`` is the credential. This package
4
+ does not read or send an API key.
5
+ """
6
+
7
+ from grokbuildstreaming.stream import (
8
+ DEFAULT_MODEL,
9
+ GrokBuildError,
10
+ collect_text,
11
+ find_grok_bin,
12
+ format_prompt,
13
+ stream_text,
14
+ subscription_env,
15
+ )
16
+
17
+ __version__ = "0.1.0"
18
+
19
+ __all__ = [
20
+ "DEFAULT_MODEL",
21
+ "GrokBuildError",
22
+ "collect_text",
23
+ "find_grok_bin",
24
+ "format_prompt",
25
+ "stream_text",
26
+ "subscription_env",
27
+ ]
@@ -0,0 +1,409 @@
1
+ """Headless Grok Build text streaming.
2
+
3
+ Each call starts one ``grok`` process and yields ``text`` events from
4
+ ``--output-format streaming-json``. The process uses the signed-in
5
+ subscription session. ``XAI_API_KEY`` is removed from the child environment
6
+ so an exported API key is not the credential.
7
+ """
8
+
9
+ from __future__ import annotations
10
+
11
+ import asyncio
12
+ import json
13
+ import os
14
+ import shutil
15
+ import signal
16
+ import tempfile
17
+ from collections.abc import Mapping, Sequence
18
+ from typing import Any
19
+
20
+ DEFAULT_MODEL = "grok-4.7"
21
+ STREAM_LIMIT = 8 * 1024 * 1024
22
+ # A per-model api_key in ~/.grok/config.toml still outranks the login session.
23
+ # These variables are the environment fallback and are removed on purpose.
24
+ _API_KEY_ENV = ("XAI_API_KEY", "XAI_API_KEY_FILE")
25
+ _TEXT_ONLY_RULE = (
26
+ "Reply with the answer text only. "
27
+ "Do not call tools, read or edit files, browse the web, or run commands."
28
+ )
29
+ # Internal tool ids. Both shell names appear across CLI versions.
30
+ _DISALLOWED_TOOLS = ",".join((
31
+ "run_terminal_command",
32
+ "run_terminal_cmd",
33
+ "read_file",
34
+ "search_replace",
35
+ "write",
36
+ "list_dir",
37
+ "grep",
38
+ "web_search",
39
+ "web_fetch",
40
+ "todo_write",
41
+ "spawn_subagent",
42
+ "kill_command_or_subagent",
43
+ "get_command_or_subagent_output",
44
+ "scheduler_create",
45
+ "scheduler_delete",
46
+ "scheduler_list",
47
+ "monitor",
48
+ "search_tool",
49
+ "use_tool",
50
+ "workflow",
51
+ "enter_plan_mode",
52
+ "exit_plan_mode",
53
+ "ask_user_question",
54
+ "image_gen",
55
+ "image_edit",
56
+ "image_to_video",
57
+ "reference_to_video",
58
+ "send_feedback",
59
+ "Agent",
60
+ ))
61
+
62
+
63
+ class GrokBuildError(RuntimeError):
64
+ """The Grok Build CLI is missing, or a turn failed before any text."""
65
+
66
+
67
+ def find_grok_bin() -> str:
68
+ """Return the ``grok`` executable from ``PATH`` or ``~/.grok/bin/grok``."""
69
+ found = shutil.which("grok")
70
+ if found and os.path.isfile(found) and os.access(found, os.X_OK):
71
+ return found
72
+ fallback = os.path.expanduser("~/.grok/bin/grok")
73
+ if os.path.isfile(fallback) and os.access(fallback, os.X_OK):
74
+ return fallback
75
+ raise GrokBuildError(
76
+ "The grok CLI was not found. Install Grok Build, then sign in with: grok login"
77
+ )
78
+
79
+
80
+ def subscription_env(base: Mapping[str, str] | None = None) -> dict[str, str]:
81
+ """Environment for a child process that keeps the ``grok login`` session."""
82
+ env = dict(os.environ if base is None else base)
83
+ for name in _API_KEY_ENV:
84
+ env.pop(name, None)
85
+ env["GROK_DISABLE_AUTOUPDATER"] = "1"
86
+ return env
87
+
88
+
89
+ def _content_to_text(content: Any) -> str:
90
+ if content is None:
91
+ return ""
92
+ if isinstance(content, str):
93
+ return content.strip()
94
+ if isinstance(content, list):
95
+ parts: list[str] = []
96
+ for item in content:
97
+ if isinstance(item, str):
98
+ parts.append(item)
99
+ elif isinstance(item, dict):
100
+ text = item.get("text")
101
+ if not isinstance(text, str):
102
+ text = item.get("content")
103
+ if isinstance(text, str):
104
+ parts.append(text)
105
+ return "\n".join(part for part in parts if part).strip()
106
+ return str(content).strip()
107
+
108
+
109
+ def format_prompt(
110
+ messages: Sequence[Mapping[str, Any]] | None = None,
111
+ follow_up: str | None = None,
112
+ system: str | None = None,
113
+ ) -> tuple[str, str]:
114
+ """Split chat messages into a user prompt and a system prompt.
115
+
116
+ A lone user turn is left unlabeled so the model sees that text exactly.
117
+ Earlier turns are labeled when history is present. System messages are
118
+ not copied into the user prompt.
119
+ """
120
+ system_parts: list[str] = []
121
+ if isinstance(system, str) and system.strip():
122
+ system_parts.append(system.strip())
123
+ turns: list[tuple[str, str]] = []
124
+ for message in messages or ():
125
+ role = str(message.get("role") or "user").strip().lower()
126
+ content = _content_to_text(message.get("content"))
127
+ if not content:
128
+ continue
129
+ if role == "system":
130
+ system_parts.append(content)
131
+ elif role == "assistant":
132
+ turns.append(("Assistant", content))
133
+ else:
134
+ turns.append(("User", content))
135
+ if isinstance(follow_up, str) and follow_up.strip():
136
+ turns.append(("User", follow_up.strip()))
137
+ system_parts.append(_TEXT_ONLY_RULE)
138
+ if not turns:
139
+ raise GrokBuildError("No prompt to send.")
140
+ if len(turns) == 1 and turns[0][0] == "User":
141
+ prompt = turns[0][1]
142
+ else:
143
+ prompt = "\n\n".join(f"{role}:\n{content}" for role, content in turns)
144
+ return prompt, "\n\n".join(system_parts)
145
+
146
+
147
+ def build_command(
148
+ grok_bin: str,
149
+ prompt_path: str,
150
+ work_dir: str,
151
+ system_prompt: str,
152
+ model: str,
153
+ max_turns: int = 1,
154
+ ) -> list[str]:
155
+ """Argv for one text-only headless turn. No API key and no tool approval."""
156
+ return [
157
+ grok_bin,
158
+ "--prompt-file", prompt_path,
159
+ "--output-format", "streaming-json",
160
+ "--cwd", work_dir,
161
+ "-m", model,
162
+ "--max-turns", str(max_turns),
163
+ "--no-plan",
164
+ "--no-subagents",
165
+ "--no-auto-update",
166
+ "--verbatim",
167
+ "--disable-web-search",
168
+ "--permission-mode", "dontAsk",
169
+ "--disallowed-tools", _DISALLOWED_TOOLS,
170
+ "--system-prompt-override", system_prompt,
171
+ ]
172
+
173
+
174
+ def parse_stream_line(line: str) -> dict[str, Any] | None:
175
+ """Parse one stdout line from ``--output-format streaming-json``."""
176
+ text = line.strip()
177
+ if not text:
178
+ return None
179
+ try:
180
+ event = json.loads(text)
181
+ except json.JSONDecodeError:
182
+ return None
183
+ if isinstance(event, dict):
184
+ return event
185
+ return None
186
+
187
+
188
+ def text_from_event(event: Mapping[str, Any]) -> str | None:
189
+ """Return a response text chunk. Other event types contribute no answer text."""
190
+ if event.get("type") != "text":
191
+ return None
192
+ data = event.get("data")
193
+ if isinstance(data, str) and data:
194
+ return data
195
+ return None
196
+
197
+
198
+ def _completion_workspace() -> str:
199
+ """Stable directory outside the caller's project, with no git root of its own."""
200
+ path = os.path.join(tempfile.gettempdir(), "grokbuildstreaming-workspace")
201
+ os.makedirs(path, exist_ok=True)
202
+ return path
203
+
204
+
205
+ async def _cancel_task(task: asyncio.Task | None) -> None:
206
+ if task is None or task.done():
207
+ return
208
+ task.cancel()
209
+ try:
210
+ await task
211
+ except (asyncio.CancelledError, Exception):
212
+ return
213
+
214
+
215
+ async def _next_line(stream: asyncio.StreamReader, cancel_event: asyncio.Event | None) -> bytes | None:
216
+ """Read one stdout line. ``None`` means the caller cancelled."""
217
+ read_task = asyncio.create_task(stream.readline())
218
+ if cancel_event is None:
219
+ return await read_task
220
+ if cancel_event.is_set():
221
+ await _cancel_task(read_task)
222
+ return None
223
+ cancel_task = asyncio.create_task(cancel_event.wait())
224
+ try:
225
+ done, _pending = await asyncio.wait(
226
+ {read_task, cancel_task},
227
+ return_when=asyncio.FIRST_COMPLETED,
228
+ )
229
+ except asyncio.CancelledError:
230
+ await _cancel_task(read_task)
231
+ await _cancel_task(cancel_task)
232
+ raise
233
+ if read_task in done and not read_task.cancelled():
234
+ await _cancel_task(cancel_task)
235
+ return read_task.result()
236
+ await _cancel_task(read_task)
237
+ await _cancel_task(cancel_task)
238
+ return None
239
+
240
+
241
+ async def _read_stderr(stream: asyncio.StreamReader | None) -> str:
242
+ if stream is None:
243
+ return ""
244
+ chunks: list[str] = []
245
+ size = 0
246
+ try:
247
+ while True:
248
+ line = await stream.readline()
249
+ if not line:
250
+ break
251
+ text = line.decode("utf-8", errors="replace")
252
+ chunks.append(text)
253
+ size += len(text)
254
+ if size > 16000:
255
+ chunks = chunks[-20:]
256
+ size = sum(len(item) for item in chunks)
257
+ except asyncio.CancelledError:
258
+ return "".join(chunks)[-8000:]
259
+ return "".join(chunks)[-8000:]
260
+
261
+
262
+ async def _stop_process(process: asyncio.subprocess.Process | None) -> None:
263
+ if process is None or process.returncode is not None:
264
+ return
265
+ if os.name == "nt":
266
+ process.terminate()
267
+ else:
268
+ try:
269
+ os.killpg(process.pid, signal.SIGTERM)
270
+ except ProcessLookupError:
271
+ return
272
+ except Exception:
273
+ process.terminate()
274
+ try:
275
+ await asyncio.wait_for(process.wait(), timeout=3)
276
+ except asyncio.TimeoutError:
277
+ if os.name == "nt":
278
+ process.kill()
279
+ else:
280
+ try:
281
+ os.killpg(process.pid, signal.SIGKILL)
282
+ except ProcessLookupError:
283
+ return
284
+ except Exception:
285
+ process.kill()
286
+ try:
287
+ await process.wait()
288
+ except Exception:
289
+ return
290
+
291
+
292
+ async def stream_text(
293
+ messages: Sequence[Mapping[str, Any]] | None = None,
294
+ follow_up: str | None = None,
295
+ system: str | None = None,
296
+ model: str | None = None,
297
+ grok_bin: str | None = None,
298
+ cancel_event: asyncio.Event | None = None,
299
+ max_turns: int = 1,
300
+ ):
301
+ """Yield text chunks from one signed-in Grok Build turn.
302
+
303
+ Closing the generator, or setting ``cancel_event``, stops the CLI process.
304
+ ``model`` defaults to :data:`DEFAULT_MODEL`. The working directory is a
305
+ temporary folder, and this call does not resume an earlier session.
306
+ """
307
+ prompt, system_prompt = format_prompt(messages, follow_up=follow_up, system=system)
308
+ executable = grok_bin or find_grok_bin()
309
+ chosen_model = model.strip() if isinstance(model, str) and model.strip() else DEFAULT_MODEL
310
+ work_dir = _completion_workspace()
311
+ prompt_handle = tempfile.NamedTemporaryFile(
312
+ mode="w",
313
+ encoding="utf-8",
314
+ prefix="grokbuildstreaming-",
315
+ suffix=".txt",
316
+ delete=False,
317
+ )
318
+ prompt_path = prompt_handle.name
319
+ process: asyncio.subprocess.Process | None = None
320
+ stderr_task: asyncio.Task | None = None
321
+ produced_text = False
322
+ try:
323
+ prompt_handle.write(prompt)
324
+ prompt_handle.close()
325
+ command = build_command(
326
+ executable,
327
+ prompt_path,
328
+ work_dir,
329
+ system_prompt,
330
+ chosen_model,
331
+ max_turns=max_turns,
332
+ )
333
+ process = await asyncio.create_subprocess_exec(
334
+ *command,
335
+ stdin=asyncio.subprocess.DEVNULL,
336
+ stdout=asyncio.subprocess.PIPE,
337
+ stderr=asyncio.subprocess.PIPE,
338
+ cwd=work_dir,
339
+ env=subscription_env(),
340
+ start_new_session=True,
341
+ limit=STREAM_LIMIT,
342
+ )
343
+ if process.stdout is None:
344
+ raise GrokBuildError("grok did not provide an output stream")
345
+ stderr_task = asyncio.create_task(_read_stderr(process.stderr))
346
+ while True:
347
+ if cancel_event is not None and cancel_event.is_set():
348
+ return
349
+ line = await _next_line(process.stdout, cancel_event)
350
+ if line is None:
351
+ return
352
+ if line == b"":
353
+ break
354
+ event = parse_stream_line(line.decode("utf-8", errors="replace"))
355
+ if not event:
356
+ continue
357
+ if event.get("type") == "error":
358
+ if cancel_event is not None and cancel_event.is_set():
359
+ return
360
+ message = event.get("message") or "Grok Build reported an error."
361
+ raise GrokBuildError(str(message))
362
+ chunk = text_from_event(event)
363
+ if chunk:
364
+ produced_text = True
365
+ yield chunk
366
+ returncode = await process.wait()
367
+ stderr_text = ""
368
+ if stderr_task is not None:
369
+ stderr_text = (await stderr_task).strip()
370
+ stderr_task = None
371
+ if returncode not in (0, None) and not produced_text:
372
+ detail = stderr_text or f"grok exited with code {returncode}"
373
+ raise GrokBuildError(detail)
374
+ finally:
375
+ await _stop_process(process)
376
+ if stderr_task is not None and not stderr_task.done():
377
+ stderr_task.cancel()
378
+ try:
379
+ await stderr_task
380
+ except (asyncio.CancelledError, Exception):
381
+ pass
382
+ try:
383
+ os.remove(prompt_path)
384
+ except OSError:
385
+ pass
386
+
387
+
388
+ async def collect_text(
389
+ messages: Sequence[Mapping[str, Any]] | None = None,
390
+ follow_up: str | None = None,
391
+ system: str | None = None,
392
+ model: str | None = None,
393
+ grok_bin: str | None = None,
394
+ cancel_event: asyncio.Event | None = None,
395
+ max_turns: int = 1,
396
+ ) -> str:
397
+ """Run :func:`stream_text` and return the joined text."""
398
+ chunks: list[str] = []
399
+ async for chunk in stream_text(
400
+ messages,
401
+ follow_up=follow_up,
402
+ system=system,
403
+ model=model,
404
+ grok_bin=grok_bin,
405
+ cancel_event=cancel_event,
406
+ max_turns=max_turns,
407
+ ):
408
+ chunks.append(chunk)
409
+ return "".join(chunks)
@@ -0,0 +1,51 @@
1
+ Metadata-Version: 2.4
2
+ Name: grokbuildstreaming
3
+ Version: 0.1.0
4
+ Summary: Stream text from the signed-in Grok Build CLI
5
+ Author: Eliran Wong
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # grokbuildstreaming
10
+
11
+ Stream text from the [Grok Build](https://x.ai) CLI. This library is a text-completion alternative to [agentmake](https://github.com/eliranwong/agentmake): you pass chat messages, and it yields response text as the CLI produces it.
12
+
13
+ Authentication is the subscription session from `grok login` (`~/.grok/auth.json`). The child process does not receive `XAI_API_KEY`. No API key is read or sent.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install -e /path/to/grokbuildstreaming
19
+ ```
20
+
21
+ Requires the `grok` executable on `PATH` or at `~/.grok/bin/grok`, and a completed `grok login`.
22
+
23
+ ## Stream
24
+
25
+ ```python
26
+ import asyncio
27
+ from grokbuildstreaming import stream_text
28
+
29
+ async def main():
30
+ messages = [
31
+ {"role": "system", "content": "You are a concise Bible study assistant."},
32
+ {"role": "user", "content": "What is John 3:16 about?"},
33
+ {"role": "assistant", "content": "It states God's love for the world."},
34
+ ]
35
+ async for chunk in stream_text(messages, follow_up="Quote it briefly.", model="grok-4.7"):
36
+ print(chunk, end="", flush=True)
37
+
38
+ asyncio.run(main())
39
+ ```
40
+
41
+ `messages` use `role` and `content`, the same shape agentmake accepts. `follow_up` is the newest user turn. System text is sent with `--system-prompt-override`. Each call is a new headless process: the library does not resume a previous Grok session.
42
+
43
+ The process is text-only. It runs with `--max-turns 1`, plan mode off, web search off, subagents off, and built-in tools removed, in a temporary working directory. Cancel an in-flight stream by setting the `asyncio.Event` passed as `cancel_event`.
44
+
45
+ `collect_text` returns the whole reply:
46
+
47
+ ```python
48
+ from grokbuildstreaming import collect_text
49
+
50
+ text = await collect_text(follow_up="Reply with exactly: pong")
51
+ ```
@@ -0,0 +1,6 @@
1
+ grokbuildstreaming/__init__.py,sha256=b7IVM4mUebgVc3gk1XWGMQwujIC9rdxQp7yqzSynKN8,526
2
+ grokbuildstreaming/stream.py,sha256=1OWsyJ-SVDT3z_XpuXl3LVkSbuCAl8cWUKX_pHXLEy8,13109
3
+ grokbuildstreaming-0.1.0.dist-info/METADATA,sha256=JZF2cx2CK-qBgssflSA8WLW-s-cP_MPn5A0_mDOeEdM,1963
4
+ grokbuildstreaming-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ grokbuildstreaming-0.1.0.dist-info/top_level.txt,sha256=V4yMQaeL8XYkd5X34FqCG49NYkMMV-aB2AcXDf5Qt58,19
6
+ grokbuildstreaming-0.1.0.dist-info/RECORD,,
@@ -0,0 +1,5 @@
1
+ Wheel-Version: 1.0
2
+ Generator: setuptools (84.0.0)
3
+ Root-Is-Purelib: true
4
+ Tag: py3-none-any
5
+
@@ -0,0 +1 @@
1
+ grokbuildstreaming