claudecodestreaming 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,29 @@
1
+ """Stream text from the signed-in Claude Code CLI.
2
+
3
+ The CLI session created by ``claude auth login`` is the credential. This
4
+ package does not read or send an API key.
5
+ """
6
+
7
+ from claudecodestreaming.stream import (
8
+ DEFAULT_MODEL,
9
+ ClaudeCodeError,
10
+ collect_text,
11
+ find_claude_bin,
12
+ format_prompt,
13
+ message_text_from_event,
14
+ stream_text,
15
+ subscription_env,
16
+ )
17
+
18
+ __version__ = "0.1.0"
19
+
20
+ __all__ = [
21
+ "DEFAULT_MODEL",
22
+ "ClaudeCodeError",
23
+ "collect_text",
24
+ "find_claude_bin",
25
+ "format_prompt",
26
+ "message_text_from_event",
27
+ "stream_text",
28
+ "subscription_env",
29
+ ]
@@ -0,0 +1,419 @@
1
+ """Headless Claude Code text streaming.
2
+
3
+ Each call starts one ``claude`` process and yields ``text_delta`` chunks from
4
+ ``--output-format stream-json`` with ``--include-partial-messages``. The
5
+ process uses the signed-in subscription session. ``ANTHROPIC_API_KEY`` is
6
+ removed from the child environment so an exported API key is not the
7
+ credential; ``ANTHROPIC_AUTH_TOKEN`` is kept, because a subscription token
8
+ from ``claude setup-token`` may live there.
9
+ """
10
+
11
+ from __future__ import annotations
12
+
13
+ import asyncio
14
+ import json
15
+ import os
16
+ import shutil
17
+ import signal
18
+ import tempfile
19
+ from collections.abc import Mapping, Sequence
20
+ from typing import Any
21
+
22
+ # None means the CLI's configured default model, so the library works with any
23
+ # sign-in. Pass model="sonnet" or a full model name to pin one.
24
+ DEFAULT_MODEL: str | None = None
25
+ STREAM_LIMIT = 8 * 1024 * 1024
26
+ # ANTHROPIC_API_KEY outranks the login session and would bill an API key
27
+ # instead of the subscription, so it is removed on purpose.
28
+ # ANTHROPIC_AUTH_TOKEN is kept: a subscription token from ``claude
29
+ # setup-token`` is exported there, and removing it would log the child out.
30
+ _API_KEY_ENV = ("ANTHROPIC_API_KEY",)
31
+ # The system prompt owns the task. The user message is the input to that task.
32
+ _TEXT_ONLY_RULE = (
33
+ "Do not call tools, read or edit files, browse the web, or run commands. "
34
+ "Obey the system prompt. The user message is the input to that task. "
35
+ "When the system prompt asks for a specification or a formatted block instead of a direct answer, produce only that specification."
36
+ )
37
+
38
+
39
+ class ClaudeCodeError(RuntimeError):
40
+ """The Claude Code CLI is missing, or a turn failed before any text."""
41
+
42
+
43
+ def find_claude_bin() -> str:
44
+ """Return the ``claude`` executable from ``PATH`` or a known install path."""
45
+ found = shutil.which("claude")
46
+ if found and os.path.isfile(found) and os.access(found, os.X_OK):
47
+ return found
48
+ for location in ("~/.claude/local/claude", "~/.local/bin/claude"):
49
+ fallback = os.path.expanduser(location)
50
+ if os.path.isfile(fallback) and os.access(fallback, os.X_OK):
51
+ return fallback
52
+ raise ClaudeCodeError(
53
+ "The claude CLI was not found. Install Claude Code, then sign in with: claude auth login"
54
+ )
55
+
56
+
57
+ def subscription_env(base: Mapping[str, str] | None = None) -> dict[str, str]:
58
+ """Environment for a child process that keeps the ``claude`` login session."""
59
+ env = dict(os.environ if base is None else base)
60
+ for name in _API_KEY_ENV:
61
+ env.pop(name, None)
62
+ env["DISABLE_AUTOUPDATER"] = "1"
63
+ return env
64
+
65
+
66
+ def _content_to_text(content: Any) -> str:
67
+ if content is None:
68
+ return ""
69
+ if isinstance(content, str):
70
+ return content.strip()
71
+ if isinstance(content, list):
72
+ parts: list[str] = []
73
+ for item in content:
74
+ if isinstance(item, str):
75
+ parts.append(item)
76
+ elif isinstance(item, dict):
77
+ text = item.get("text")
78
+ if not isinstance(text, str):
79
+ text = item.get("content")
80
+ if isinstance(text, str):
81
+ parts.append(text)
82
+ return "\n".join(part for part in parts if part).strip()
83
+ return str(content).strip()
84
+
85
+
86
+ def format_prompt(
87
+ messages: Sequence[Mapping[str, Any]] | None = None,
88
+ follow_up: str | None = None,
89
+ system: str | None = None,
90
+ ) -> tuple[str, str]:
91
+ """Split chat messages into a user prompt and a system prompt.
92
+
93
+ A lone user turn is left unlabeled so the model sees that text exactly.
94
+ Earlier turns are labeled when history is present. The explicit ``system``
95
+ argument is the system prompt. System messages already in ``messages`` stay
96
+ in the user prompt, so a generic identity does not outrank the task.
97
+ """
98
+ system_parts: list[str] = []
99
+ if isinstance(system, str) and system.strip():
100
+ system_parts.append(system.strip())
101
+ standing: list[str] = []
102
+ turns: list[tuple[str, str]] = []
103
+ for message in messages or ():
104
+ role = str(message.get("role") or "user").strip().lower()
105
+ content = _content_to_text(message.get("content"))
106
+ if not content:
107
+ continue
108
+ if role == "system":
109
+ standing.append(content)
110
+ elif role == "assistant":
111
+ turns.append(("Assistant", content))
112
+ else:
113
+ turns.append(("User", content))
114
+ if isinstance(follow_up, str) and follow_up.strip():
115
+ turns.append(("User", follow_up.strip()))
116
+ system_parts.append(_TEXT_ONLY_RULE)
117
+ if not turns and not standing:
118
+ raise ClaudeCodeError("No prompt to send.")
119
+ if len(turns) == 1 and turns[0][0] == "User":
120
+ prompt = turns[0][1]
121
+ else:
122
+ prompt = "\n\n".join(f"{role}:\n{content}" for role, content in turns)
123
+ if standing:
124
+ context = "\n\n".join(standing)
125
+ prompt = f"{context}\n\n{prompt}" if prompt else context
126
+ return prompt, "\n\n".join(system_parts)
127
+
128
+
129
+ def build_command(
130
+ claude_bin: str,
131
+ system_prompt: str,
132
+ model: str | None = None,
133
+ ) -> list[str]:
134
+ """Argv for one text-only headless turn. No API key and no tool approval.
135
+
136
+ ``--tools ""`` removes every built-in tool, so the turn cannot branch and
137
+ no permission can be asked. ``--strict-mcp-config`` with no ``--mcp-config``
138
+ loads no MCP servers.
139
+ """
140
+ command = [
141
+ claude_bin,
142
+ "--print",
143
+ "--verbose",
144
+ "--output-format", "stream-json",
145
+ "--include-partial-messages",
146
+ "--tools", "",
147
+ "--strict-mcp-config",
148
+ "--no-session-persistence",
149
+ "--permission-mode", "bypassPermissions",
150
+ "--system-prompt", system_prompt,
151
+ ]
152
+ chosen_model = model.strip() if isinstance(model, str) and model.strip() else DEFAULT_MODEL
153
+ if chosen_model:
154
+ command.extend(("--model", chosen_model))
155
+ return command
156
+
157
+
158
+ def parse_stream_line(line: str) -> dict[str, Any] | None:
159
+ """Parse one stdout line from ``--output-format stream-json``."""
160
+ text = line.strip()
161
+ if not text:
162
+ return None
163
+ try:
164
+ event = json.loads(text)
165
+ except json.JSONDecodeError:
166
+ return None
167
+ if isinstance(event, dict):
168
+ return event
169
+ return None
170
+
171
+
172
+ def text_from_event(event: Mapping[str, Any]) -> str | None:
173
+ """Return a streamed text chunk. Other event types contribute no answer text."""
174
+ if event.get("type") != "stream_event":
175
+ return None
176
+ inner = event.get("event")
177
+ if not isinstance(inner, dict) or inner.get("type") != "content_block_delta":
178
+ return None
179
+ delta = inner.get("delta")
180
+ if isinstance(delta, dict) and delta.get("type") == "text_delta":
181
+ text = delta.get("text")
182
+ if isinstance(text, str) and text:
183
+ return text
184
+ return None
185
+
186
+
187
+ def message_text_from_event(event: Mapping[str, Any]) -> str | None:
188
+ """Return text from a whole ``assistant`` message event.
189
+
190
+ This is the fallback for CLI versions that do not stream partial
191
+ messages. API error messages are not answer text and yield ``None``.
192
+ """
193
+ if event.get("type") != "assistant" or event.get("is_api_error_message"):
194
+ return None
195
+ message = event.get("message")
196
+ if not isinstance(message, dict):
197
+ return None
198
+ content = message.get("content")
199
+ if not isinstance(content, list):
200
+ return None
201
+ parts: list[str] = []
202
+ for block in content:
203
+ if isinstance(block, dict) and block.get("type") == "text":
204
+ text = block.get("text")
205
+ if isinstance(text, str) and text:
206
+ parts.append(text)
207
+ return "".join(parts) or None
208
+
209
+
210
+ def _completion_workspace() -> str:
211
+ """Stable directory outside the caller's project, with no git root of its own."""
212
+ path = os.path.join(tempfile.gettempdir(), "claudecodestreaming-workspace")
213
+ os.makedirs(path, exist_ok=True)
214
+ return path
215
+
216
+
217
+ async def _cancel_task(task: asyncio.Task | None) -> None:
218
+ if task is None or task.done():
219
+ return
220
+ task.cancel()
221
+ try:
222
+ await task
223
+ except (asyncio.CancelledError, Exception):
224
+ return
225
+
226
+
227
+ async def _next_line(stream: asyncio.StreamReader, cancel_event: asyncio.Event | None) -> bytes | None:
228
+ """Read one stdout line. ``None`` means the caller cancelled."""
229
+ read_task = asyncio.create_task(stream.readline())
230
+ if cancel_event is None:
231
+ return await read_task
232
+ if cancel_event.is_set():
233
+ await _cancel_task(read_task)
234
+ return None
235
+ cancel_task = asyncio.create_task(cancel_event.wait())
236
+ try:
237
+ done, _pending = await asyncio.wait(
238
+ {read_task, cancel_task},
239
+ return_when=asyncio.FIRST_COMPLETED,
240
+ )
241
+ except asyncio.CancelledError:
242
+ await _cancel_task(read_task)
243
+ await _cancel_task(cancel_task)
244
+ raise
245
+ if read_task in done and not read_task.cancelled():
246
+ await _cancel_task(cancel_task)
247
+ return read_task.result()
248
+ await _cancel_task(read_task)
249
+ await _cancel_task(cancel_task)
250
+ return None
251
+
252
+
253
+ async def _read_stderr(stream: asyncio.StreamReader | None) -> str:
254
+ if stream is None:
255
+ return ""
256
+ chunks: list[str] = []
257
+ size = 0
258
+ try:
259
+ while True:
260
+ line = await stream.readline()
261
+ if not line:
262
+ break
263
+ text = line.decode("utf-8", errors="replace")
264
+ chunks.append(text)
265
+ size += len(text)
266
+ if size > 16000:
267
+ chunks = chunks[-20:]
268
+ size = sum(len(item) for item in chunks)
269
+ except asyncio.CancelledError:
270
+ return "".join(chunks)[-8000:]
271
+ return "".join(chunks)[-8000:]
272
+
273
+
274
+ async def _stop_process(process: asyncio.subprocess.Process | None) -> None:
275
+ if process is None or process.returncode is not None:
276
+ return
277
+ if os.name == "nt":
278
+ process.terminate()
279
+ else:
280
+ try:
281
+ os.killpg(process.pid, signal.SIGTERM)
282
+ except ProcessLookupError:
283
+ return
284
+ except Exception:
285
+ process.terminate()
286
+ try:
287
+ await asyncio.wait_for(process.wait(), timeout=3)
288
+ except asyncio.TimeoutError:
289
+ if os.name == "nt":
290
+ process.kill()
291
+ else:
292
+ try:
293
+ os.killpg(process.pid, signal.SIGKILL)
294
+ except ProcessLookupError:
295
+ return
296
+ except Exception:
297
+ process.kill()
298
+ try:
299
+ await process.wait()
300
+ except Exception:
301
+ return
302
+
303
+
304
+ async def stream_text(
305
+ messages: Sequence[Mapping[str, Any]] | None = None,
306
+ follow_up: str | None = None,
307
+ system: str | None = None,
308
+ model: str | None = None,
309
+ claude_bin: str | None = None,
310
+ cancel_event: asyncio.Event | None = None,
311
+ ):
312
+ """Yield text chunks from one signed-in Claude Code turn.
313
+
314
+ Closing the generator, or setting ``cancel_event``, stops the CLI process.
315
+ ``model`` defaults to :data:`DEFAULT_MODEL` (the CLI's configured model).
316
+ The working directory is a temporary folder, and this call does not resume
317
+ an earlier session.
318
+ """
319
+ prompt, system_prompt = format_prompt(messages, follow_up=follow_up, system=system)
320
+ executable = claude_bin or find_claude_bin()
321
+ work_dir = _completion_workspace()
322
+ process: asyncio.subprocess.Process | None = None
323
+ stderr_task: asyncio.Task | None = None
324
+ produced_text = False
325
+ try:
326
+ command = build_command(executable, system_prompt, model=model)
327
+ process = await asyncio.create_subprocess_exec(
328
+ *command,
329
+ stdin=asyncio.subprocess.PIPE,
330
+ stdout=asyncio.subprocess.PIPE,
331
+ stderr=asyncio.subprocess.PIPE,
332
+ cwd=work_dir,
333
+ env=subscription_env(),
334
+ start_new_session=True,
335
+ limit=STREAM_LIMIT,
336
+ )
337
+ if process.stdin is None or process.stdout is None:
338
+ raise ClaudeCodeError("claude did not provide usable streams")
339
+ # The prompt travels on stdin: no argv length limit, nothing in `ps`.
340
+ try:
341
+ process.stdin.write(prompt.encode("utf-8"))
342
+ await process.stdin.drain()
343
+ except (BrokenPipeError, ConnectionResetError):
344
+ pass
345
+ process.stdin.close()
346
+ stderr_task = asyncio.create_task(_read_stderr(process.stderr))
347
+ while True:
348
+ if cancel_event is not None and cancel_event.is_set():
349
+ return
350
+ line = await _next_line(process.stdout, cancel_event)
351
+ if line is None:
352
+ return
353
+ if line == b"":
354
+ break
355
+ event = parse_stream_line(line.decode("utf-8", errors="replace"))
356
+ if not event:
357
+ continue
358
+ if event.get("type") == "result":
359
+ if cancel_event is not None and cancel_event.is_set():
360
+ return
361
+ if event.get("is_error"):
362
+ message = event.get("result") or "Claude Code reported an error."
363
+ raise ClaudeCodeError(str(message))
364
+ # The result repeats the whole reply once the deltas are done.
365
+ if not produced_text:
366
+ text = event.get("result")
367
+ if isinstance(text, str) and text:
368
+ produced_text = True
369
+ yield text
370
+ continue
371
+ chunk = text_from_event(event)
372
+ if chunk:
373
+ produced_text = True
374
+ yield chunk
375
+ continue
376
+ # Without partial messages the whole assistant message is the text.
377
+ if not produced_text:
378
+ chunk = message_text_from_event(event)
379
+ if chunk:
380
+ produced_text = True
381
+ yield chunk
382
+ returncode = await process.wait()
383
+ stderr_text = ""
384
+ if stderr_task is not None:
385
+ stderr_text = (await stderr_task).strip()
386
+ stderr_task = None
387
+ if returncode not in (0, None) and not produced_text:
388
+ detail = stderr_text or f"claude exited with code {returncode}"
389
+ raise ClaudeCodeError(detail)
390
+ finally:
391
+ await _stop_process(process)
392
+ if stderr_task is not None and not stderr_task.done():
393
+ stderr_task.cancel()
394
+ try:
395
+ await stderr_task
396
+ except (asyncio.CancelledError, Exception):
397
+ pass
398
+
399
+
400
+ async def collect_text(
401
+ messages: Sequence[Mapping[str, Any]] | None = None,
402
+ follow_up: str | None = None,
403
+ system: str | None = None,
404
+ model: str | None = None,
405
+ claude_bin: str | None = None,
406
+ cancel_event: asyncio.Event | None = None,
407
+ ) -> str:
408
+ """Run :func:`stream_text` and return the joined text."""
409
+ chunks: list[str] = []
410
+ async for chunk in stream_text(
411
+ messages,
412
+ follow_up=follow_up,
413
+ system=system,
414
+ model=model,
415
+ claude_bin=claude_bin,
416
+ cancel_event=cancel_event,
417
+ ):
418
+ chunks.append(chunk)
419
+ return "".join(chunks)
@@ -0,0 +1,53 @@
1
+ Metadata-Version: 2.4
2
+ Name: claudecodestreaming
3
+ Version: 0.1.0
4
+ Summary: Stream text from the signed-in Claude Code CLI
5
+ Author: Eliran Wong
6
+ Requires-Python: >=3.10
7
+ Description-Content-Type: text/markdown
8
+
9
+ # claudecodestreaming
10
+
11
+ Stream text from the [Claude Code](https://claude.com/claude-code) 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 `claude auth login`, or a subscription token from `claude setup-token` exported as `ANTHROPIC_AUTH_TOKEN`. The child process does not receive `ANTHROPIC_API_KEY`. No API key is read or sent.
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install claudecodestreaming
19
+ ```
20
+
21
+ Requires the `claude` executable on `PATH`, at `~/.claude/local/claude`, or at `~/.local/bin/claude`, and a completed `claude auth login`.
22
+
23
+ ## Stream
24
+
25
+ ```python
26
+ import asyncio
27
+ from claudecodestreaming 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."):
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. An explicit `system` argument is sent with `--system-prompt`. System messages already in `messages` stay in the user prompt, so a generic identity does not outrank that task. Each call is a new headless process: the library does not resume a previous Claude Code session.
42
+
43
+ The process is text-only. Every built-in tool is removed (`--tools ""`), MCP servers are not loaded (`--strict-mcp-config`), session persistence is off, and it runs in a temporary working directory; with no tools there is exactly one turn. The prompt travels on stdin, so it never appears in `ps` output. Cancel an in-flight stream by setting the `asyncio.Event` passed as `cancel_event`.
44
+
45
+ `model` defaults to the CLI's configured model, so the library follows whatever `claude` is signed in and set up to run. Pass `model="sonnet"` or a full model name to pin one.
46
+
47
+ `collect_text` returns the whole reply:
48
+
49
+ ```python
50
+ from claudecodestreaming import collect_text
51
+
52
+ text = await collect_text(follow_up="Reply with exactly: pong")
53
+ ```
@@ -0,0 +1,6 @@
1
+ claudecodestreaming/__init__.py,sha256=fR7yWV2ebm5trFsi3xsubny_Fw3xGQJrZyyZhuAdPdg,600
2
+ claudecodestreaming/stream.py,sha256=O6P5IoUoWpAp6LX1f3F47jp3ruqEX2FnsA6Q2m5BoQ0,15092
3
+ claudecodestreaming-0.1.0.dist-info/METADATA,sha256=9yzfo8z18q38Q6mYKTILNe6F-5bDazoJP5J_5VuU_nk,2524
4
+ claudecodestreaming-0.1.0.dist-info/WHEEL,sha256=YVMoNqKzERt-wjUZwJ33xBGAwnFl-4cqbYkTtWa4itE,91
5
+ claudecodestreaming-0.1.0.dist-info/top_level.txt,sha256=K5HaycgInf1_R1YxBQMM8c41_FyOpUG5mH3ZigIM5ps,20
6
+ claudecodestreaming-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
+ claudecodestreaming