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 @@
|
|
|
1
|
+
claudecodestreaming
|