claudestream 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,120 @@
1
+ """A Python library and CLI for streaming Claude Code's JSON protocol."""
2
+
3
+ from claudestream._async_session import AsyncSession, ClaudeStreamError
4
+ from claudestream._sync_session import SyncSession
5
+ from claudestream.events import (
6
+ AssistantMessage,
7
+ AssistantText,
8
+ ApiRetry,
9
+ CompactBoundary,
10
+ ContentBlock,
11
+ Event,
12
+ McpRequest,
13
+ PermissionRequest,
14
+ RateLimit,
15
+ Result,
16
+ StreamDelta,
17
+ SystemInit,
18
+ TextBlock,
19
+ Thinking,
20
+ ThinkingBlock,
21
+ ToolResult,
22
+ ToolResultBlock,
23
+ ToolResultMessage,
24
+ ToolUse,
25
+ ToolUseBlock,
26
+ UnknownEvent,
27
+ Usage,
28
+ )
29
+ from claudestream.policy import (
30
+ Allow,
31
+ AllowAllPolicy,
32
+ AllowBuiltinsPolicy,
33
+ AllowListPolicy,
34
+ CallbackPolicy,
35
+ Deny,
36
+ Policy,
37
+ allow_all,
38
+ allow_builtins,
39
+ allow_list,
40
+ callback,
41
+ deny_all,
42
+ )
43
+
44
+ __all__ = [
45
+ # Sessions
46
+ "AsyncSession",
47
+ "SyncSession",
48
+ "ClaudeStreamError",
49
+ # Events
50
+ "Event",
51
+ "SystemInit",
52
+ "ApiRetry",
53
+ "CompactBoundary",
54
+ "AssistantMessage",
55
+ "AssistantText",
56
+ "ToolResultMessage",
57
+ "ToolUse",
58
+ "ToolResult",
59
+ "Thinking",
60
+ "StreamDelta",
61
+ "Result",
62
+ "RateLimit",
63
+ "PermissionRequest",
64
+ "McpRequest",
65
+ "UnknownEvent",
66
+ # Content blocks
67
+ "TextBlock",
68
+ "ToolUseBlock",
69
+ "ThinkingBlock",
70
+ "ToolResultBlock",
71
+ "ContentBlock",
72
+ "Usage",
73
+ # Policy
74
+ "Policy",
75
+ "Allow",
76
+ "Deny",
77
+ "AllowAllPolicy",
78
+ "AllowBuiltinsPolicy",
79
+ "AllowListPolicy",
80
+ "CallbackPolicy",
81
+ "allow_all",
82
+ "deny_all",
83
+ "allow_builtins",
84
+ "allow_list",
85
+ "callback",
86
+ # Convenience
87
+ "print_prompt",
88
+ ]
89
+
90
+
91
+ def print_prompt(
92
+ prompt: str,
93
+ *,
94
+ model: str | None = None,
95
+ cwd: str | None = None,
96
+ binary: str | None = None,
97
+ policy: Policy | None = None,
98
+ system_prompt: str | None = None,
99
+ extra_args: list[str] | None = None,
100
+ env: dict[str, str] | None = None,
101
+ ) -> str:
102
+ """One-shot convenience: send a prompt and return the full response text.
103
+
104
+ Creates a SyncSession, sends one message, collects AssistantText events,
105
+ and returns the concatenated text. For claudewheel integration.
106
+ """
107
+ parts: list[str] = []
108
+ with SyncSession(
109
+ model=model,
110
+ cwd=cwd,
111
+ binary=binary,
112
+ policy=policy,
113
+ system_prompt=system_prompt,
114
+ extra_args=extra_args,
115
+ env=env,
116
+ ) as session:
117
+ for event in session.send(prompt):
118
+ if isinstance(event, AssistantText):
119
+ parts.append(event.text)
120
+ return "".join(parts)
@@ -0,0 +1,290 @@
1
+ """Async session for Claude Code stream-json protocol."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ import logging
7
+ from collections.abc import AsyncIterator
8
+ from typing import Any, Callable
9
+
10
+ from claudestream.events import (
11
+ Event,
12
+ McpRequest,
13
+ PermissionRequest,
14
+ Result,
15
+ SystemInit,
16
+ UnknownEvent,
17
+ )
18
+ from claudestream.messages import AllowPermission, DenyPermission, UserMessage
19
+ from claudestream.policy import Allow, Deny, Policy, policy_to_flags
20
+ from claudestream._process import ProcessConfig, ProcessManager, find_binary, check_version
21
+ from claudestream._protocol import flatten_event, read_events, write_message
22
+
23
+ log = logging.getLogger("claudestream")
24
+
25
+
26
+ class ClaudeStreamError(Exception):
27
+ """Raised when the Claude Code subprocess fails."""
28
+
29
+ def __init__(self, message: str, exit_code: int | None = None):
30
+ super().__init__(message)
31
+ self.exit_code = exit_code
32
+
33
+
34
+ class AsyncSession:
35
+ """Async session managing a Claude Code subprocess.
36
+
37
+ Usage::
38
+
39
+ async with AsyncSession(model="sonnet") as session:
40
+ async for event in session.send("hello"):
41
+ print(event)
42
+ """
43
+
44
+ def __init__(
45
+ self,
46
+ *,
47
+ model: str | None = None,
48
+ cwd: str | None = None,
49
+ binary: str | None = None,
50
+ policy: Policy | None = None,
51
+ system_prompt: str | None = None,
52
+ extra_args: list[str] | None = None,
53
+ env: dict[str, str] | None = None,
54
+ ):
55
+ self._binary = find_binary(binary)
56
+ self._policy = policy
57
+ self._callbacks: dict[type, list[Callable]] = {}
58
+ self._active_turn = False
59
+ self._last_result: Result | None = None
60
+
61
+ # Convert policy to CLI flags, then route them to ProcessConfig fields
62
+ policy_flags = policy_to_flags(policy)
63
+
64
+ permission_mode: str | None = None
65
+ permission_prompt_tool: str | None = None
66
+ allowed_tools: list[str] = []
67
+ skip_permissions = False
68
+
69
+ remaining_flags: list[str] = []
70
+ i = 0
71
+ while i < len(policy_flags):
72
+ flag = policy_flags[i]
73
+ if flag == "--dangerously-skip-permissions":
74
+ skip_permissions = True
75
+ i += 1
76
+ elif flag == "--permission-mode":
77
+ permission_mode = policy_flags[i + 1]
78
+ i += 2
79
+ elif flag == "--permission-prompt-tool":
80
+ permission_prompt_tool = policy_flags[i + 1]
81
+ i += 2
82
+ elif flag == "--allowedTools":
83
+ allowed_tools = policy_flags[i + 1].split(",")
84
+ i += 2
85
+ else:
86
+ remaining_flags.append(flag)
87
+ i += 1
88
+
89
+ all_extra = list(extra_args or [])
90
+ if skip_permissions:
91
+ all_extra.append("--dangerously-skip-permissions")
92
+ all_extra.extend(remaining_flags)
93
+
94
+ self._process_mgr = ProcessManager(ProcessConfig(
95
+ binary=self._binary,
96
+ cwd=cwd,
97
+ model=model,
98
+ system_prompt=system_prompt,
99
+ permission_mode=permission_mode,
100
+ allowed_tools=allowed_tools,
101
+ permission_prompt_tool=permission_prompt_tool,
102
+ extra_args=all_extra,
103
+ env=env,
104
+ ))
105
+
106
+ # Session metadata (populated from SystemInit)
107
+ self._session_id: str | None = None
108
+ self._model_name: str | None = None
109
+ self._tools: list[str] = []
110
+ self._claude_version: str | None = None
111
+
112
+ # --- Properties ---
113
+
114
+ @property
115
+ def session_id(self) -> str | None:
116
+ return self._session_id
117
+
118
+ @property
119
+ def model_name(self) -> str | None:
120
+ return self._model_name
121
+
122
+ @property
123
+ def tools(self) -> list[str]:
124
+ return self._tools
125
+
126
+ @property
127
+ def claude_version(self) -> str | None:
128
+ return self._claude_version
129
+
130
+ @property
131
+ def last_result(self) -> Result | None:
132
+ return self._last_result
133
+
134
+ # --- Context manager ---
135
+
136
+ async def __aenter__(self) -> AsyncSession:
137
+ await self._start()
138
+ return self
139
+
140
+ async def __aexit__(self, *exc_info: Any) -> None:
141
+ await self.close()
142
+
143
+ async def _start(self) -> None:
144
+ """Start the subprocess and read the SystemInit event."""
145
+ self._claude_version = await check_version(self._binary)
146
+
147
+ await self._process_mgr.start()
148
+
149
+ # Read events until we get SystemInit
150
+ async for event in read_events(self._process_mgr.stdout):
151
+ if isinstance(event, SystemInit):
152
+ self._session_id = event.session_id
153
+ self._model_name = event.model
154
+ self._tools = list(event.tools)
155
+ log.debug(
156
+ "session started: id=%s model=%s tools=%d",
157
+ self._session_id,
158
+ self._model_name,
159
+ len(self._tools),
160
+ )
161
+ break
162
+ elif isinstance(event, UnknownEvent):
163
+ log.debug("unknown event during init: %s", event.type)
164
+
165
+ async def close(self) -> None:
166
+ """Shut down the session and kill the subprocess."""
167
+ await self._process_mgr.close()
168
+
169
+ # --- Sending messages ---
170
+
171
+ async def send(self, prompt: str, *, raw: bool = False) -> AsyncIterator[Event]:
172
+ """Send a message and yield events until the turn completes.
173
+
174
+ Args:
175
+ prompt: The message to send.
176
+ raw: If True, yield raw protocol events (AssistantMessage,
177
+ ToolResultMessage). If False (default), yield flattened
178
+ convenience events (AssistantText, ToolUse, etc.).
179
+
180
+ Yields:
181
+ Event objects until a Result event is received.
182
+
183
+ Raises:
184
+ RuntimeError: If called while a previous turn is still active.
185
+ ClaudeStreamError: If the subprocess dies unexpectedly.
186
+ """
187
+ if self._active_turn:
188
+ raise RuntimeError(
189
+ "Cannot send while a previous turn is active. "
190
+ "Drain the event iterator or wait for the Result event."
191
+ )
192
+
193
+ self._active_turn = True
194
+ self._last_result = None
195
+
196
+ try:
197
+ msg = UserMessage(content=prompt, session_id=self._session_id or "")
198
+ await write_message(self._process_mgr.stdin, msg)
199
+
200
+ async for event in self._read_turn(raw=raw):
201
+ yield event
202
+ finally:
203
+ self._active_turn = False
204
+
205
+ async def _read_turn(self, *, raw: bool) -> AsyncIterator[Event]:
206
+ """Read events for a single turn until Result is received."""
207
+ if not self._process_mgr.is_alive:
208
+ raise ClaudeStreamError(
209
+ "Claude subprocess is not running",
210
+ exit_code=None,
211
+ )
212
+
213
+ async for event in read_events(self._process_mgr.stdout):
214
+ # Handle permission requests via policy
215
+ if isinstance(event, PermissionRequest):
216
+ handled = await self._handle_permission(event)
217
+ if handled:
218
+ continue
219
+
220
+ # Flatten or pass through
221
+ if raw:
222
+ events_to_yield = [event]
223
+ else:
224
+ events_to_yield = flatten_event(event)
225
+
226
+ for evt in events_to_yield:
227
+ # Fire callbacks before yielding
228
+ for cb in self._callbacks.get(type(evt), []):
229
+ cb(evt)
230
+ yield evt
231
+
232
+ # Turn completes on Result
233
+ if isinstance(event, Result):
234
+ self._last_result = event
235
+ return
236
+
237
+ # stdout closed without a Result event
238
+ rc = self._process_mgr._process.returncode if self._process_mgr._process else None
239
+ raise ClaudeStreamError(
240
+ "Claude subprocess closed stdout without sending a Result event",
241
+ exit_code=rc,
242
+ )
243
+
244
+ async def _handle_permission(self, request: PermissionRequest) -> bool:
245
+ """Apply policy to a permission request. Returns True if handled."""
246
+ if self._policy is None:
247
+ return False
248
+
249
+ decision = self._policy.decide(request.tool_name, request.tool_input)
250
+
251
+ if decision is None:
252
+ return False
253
+
254
+ if isinstance(decision, Allow):
255
+ updated = decision.updated_input if decision.updated_input else request.tool_input
256
+ msg = AllowPermission(
257
+ request_id=request.request_id,
258
+ updated_input=updated,
259
+ )
260
+ else:
261
+ msg = DenyPermission(
262
+ request_id=request.request_id,
263
+ message=decision.message,
264
+ )
265
+
266
+ await write_message(self._process_mgr.stdin, msg)
267
+ return True
268
+
269
+ # --- Callbacks ---
270
+
271
+ def on(self, event_type: type[Event], handler: Callable[[Any], None]) -> None:
272
+ """Register a callback for a specific event type.
273
+
274
+ The callback fires during iteration, before the event is yielded.
275
+ """
276
+ if event_type not in self._callbacks:
277
+ self._callbacks[event_type] = []
278
+ self._callbacks[event_type].append(handler)
279
+
280
+ # --- Permission response (for consumer-handled requests) ---
281
+
282
+ async def respond_allow(self, request_id: str, updated_input: dict) -> None:
283
+ """Allow a permission request that was surfaced to the consumer."""
284
+ msg = AllowPermission(request_id=request_id, updated_input=updated_input)
285
+ await write_message(self._process_mgr.stdin, msg)
286
+
287
+ async def respond_deny(self, request_id: str, message: str = "Denied by user") -> None:
288
+ """Deny a permission request that was surfaced to the consumer."""
289
+ msg = DenyPermission(request_id=request_id, message=message)
290
+ await write_message(self._process_mgr.stdin, msg)
claudestream/_cli.py ADDED
@@ -0,0 +1,208 @@
1
+ """CLI entry point for claudestream."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import json
6
+ import sys
7
+
8
+ import strictcli
9
+
10
+ from claudestream import (
11
+ AssistantMessage,
12
+ AssistantText,
13
+ AsyncSession,
14
+ ClaudeStreamError,
15
+ Event,
16
+ PermissionRequest,
17
+ RateLimit,
18
+ Result,
19
+ StreamDelta,
20
+ SyncSession,
21
+ Thinking,
22
+ ToolResult,
23
+ ToolUse,
24
+ ToolResultMessage,
25
+ ApiRetry,
26
+ SystemInit,
27
+ UnknownEvent,
28
+ allow_all,
29
+ )
30
+
31
+ app = strictcli.App(
32
+ name="claudestream",
33
+ version="0.1.0",
34
+ help="Stream Claude Code's JSON protocol",
35
+ )
36
+
37
+
38
+ # --- send command ---
39
+
40
+ @app.command("send", help="Send a prompt and display the response")
41
+ @strictcli.arg("prompt", help="The prompt to send")
42
+ @strictcli.flag("model", type=str, default="", help="Model to use (e.g. sonnet, opus)", short="m")
43
+ @strictcli.flag("cwd", type=str, default="", help="Working directory for Claude")
44
+ @strictcli.flag("raw", type=bool, default=False, help="Show raw protocol events instead of flattened")
45
+ @strictcli.flag("json-output", type=bool, default=False, help="Output events as JSON lines")
46
+ @strictcli.flag("skip-permissions", type=bool, default=False, help="Skip all permission prompts")
47
+ def cmd_send(
48
+ prompt: str,
49
+ model: str = "",
50
+ cwd: str = "",
51
+ raw: bool = False,
52
+ json_output: bool = False,
53
+ skip_permissions: bool = False,
54
+ ) -> None:
55
+ policy = allow_all() if skip_permissions else None
56
+ with SyncSession(
57
+ model=model or None,
58
+ cwd=cwd or None,
59
+ policy=policy,
60
+ ) as session:
61
+ for event in session.send(prompt, raw=raw):
62
+ if json_output:
63
+ _print_json(event)
64
+ else:
65
+ _print_event(event)
66
+
67
+
68
+ # --- stream command ---
69
+
70
+ @app.command("stream", help="Stream a prompt with real-time token output")
71
+ @strictcli.arg("prompt", help="The prompt to send")
72
+ @strictcli.flag("model", type=str, default="", help="Model to use", short="m")
73
+ @strictcli.flag("cwd", type=str, default="", help="Working directory for Claude")
74
+ @strictcli.flag("skip-permissions", type=bool, default=False, help="Skip all permission prompts")
75
+ def cmd_stream(
76
+ prompt: str,
77
+ model: str = "",
78
+ cwd: str = "",
79
+ skip_permissions: bool = False,
80
+ ) -> None:
81
+ policy = allow_all() if skip_permissions else None
82
+ with SyncSession(
83
+ model=model or None,
84
+ cwd=cwd or None,
85
+ policy=policy,
86
+ ) as session:
87
+ for event in session.send(prompt):
88
+ if isinstance(event, StreamDelta) and event.text:
89
+ sys.stdout.write(event.text)
90
+ sys.stdout.flush()
91
+ elif isinstance(event, AssistantText):
92
+ # Fallback if streaming deltas aren't available
93
+ pass
94
+ elif isinstance(event, Result):
95
+ sys.stdout.write("\n")
96
+ sys.stdout.flush()
97
+
98
+
99
+ # --- events command ---
100
+
101
+ @app.command("events", help="Debug: show all raw protocol events")
102
+ @strictcli.arg("prompt", help="The prompt to send")
103
+ @strictcli.flag("model", type=str, default="", help="Model to use", short="m")
104
+ @strictcli.flag("cwd", type=str, default="", help="Working directory for Claude")
105
+ @strictcli.flag("skip-permissions", type=bool, default=False, help="Skip all permission prompts")
106
+ def cmd_events(
107
+ prompt: str,
108
+ model: str = "",
109
+ cwd: str = "",
110
+ skip_permissions: bool = False,
111
+ ) -> None:
112
+ policy = allow_all() if skip_permissions else None
113
+ with SyncSession(
114
+ model=model or None,
115
+ cwd=cwd or None,
116
+ policy=policy,
117
+ ) as session:
118
+ for event in session.send(prompt, raw=True):
119
+ _print_json(event)
120
+
121
+
122
+ # --- repl command ---
123
+
124
+ @app.command("repl", help="Interactive multi-turn REPL")
125
+ @strictcli.flag("model", type=str, default="", help="Model to use", short="m")
126
+ @strictcli.flag("cwd", type=str, default="", help="Working directory for Claude")
127
+ @strictcli.flag("skip-permissions", type=bool, default=False, help="Skip all permission prompts")
128
+ def cmd_repl(
129
+ model: str = "",
130
+ cwd: str = "",
131
+ skip_permissions: bool = False,
132
+ ) -> None:
133
+ policy = allow_all() if skip_permissions else None
134
+ try:
135
+ with SyncSession(
136
+ model=model or None,
137
+ cwd=cwd or None,
138
+ policy=policy,
139
+ ) as session:
140
+ print(f"claudestream repl (model: {session.model_name or 'default'})")
141
+ print("Type your prompts. Ctrl-D to exit.\n")
142
+ while True:
143
+ try:
144
+ prompt = input("> ")
145
+ except EOFError:
146
+ print("\nBye.")
147
+ break
148
+ if not prompt.strip():
149
+ continue
150
+ for event in session.send(prompt):
151
+ if isinstance(event, AssistantText):
152
+ sys.stdout.write(event.text)
153
+ sys.stdout.flush()
154
+ elif isinstance(event, ToolUse):
155
+ print(f"\n[tool: {event.name}]")
156
+ elif isinstance(event, ToolResult):
157
+ content = event.content if isinstance(event.content, str) else str(event.content)
158
+ if len(content) > 200:
159
+ content = content[:200] + "..."
160
+ print(f"[result: {content}]")
161
+ elif isinstance(event, Result):
162
+ print(f"\n[cost: ${event.total_cost_usd:.4f}]")
163
+ print()
164
+ except ClaudeStreamError as e:
165
+ print(f"Error: {e}", file=sys.stderr)
166
+ sys.exit(1)
167
+ except KeyboardInterrupt:
168
+ print("\nInterrupted.")
169
+
170
+
171
+ # --- Helpers ---
172
+
173
+ def _print_event(event: Event) -> None:
174
+ """Pretty-print an event to stdout."""
175
+ if isinstance(event, AssistantText):
176
+ sys.stdout.write(event.text)
177
+ sys.stdout.flush()
178
+ elif isinstance(event, ToolUse):
179
+ print(f"\n--- Tool: {event.name} ---")
180
+ print(json.dumps(event.input, indent=2))
181
+ elif isinstance(event, ToolResult):
182
+ content = event.content if isinstance(event.content, str) else str(event.content)
183
+ if len(content) > 500:
184
+ content = content[:500] + "..."
185
+ print(f"--- Result ---\n{content}")
186
+ elif isinstance(event, Thinking):
187
+ print(f"[thinking: {event.text[:100]}...]")
188
+ elif isinstance(event, Result):
189
+ print(f"\n--- Done ({event.duration_ms:.0f}ms, ${event.total_cost_usd:.4f}) ---")
190
+ elif isinstance(event, ApiRetry):
191
+ print(f"[retry {event.attempt}/{event.max_retries}: {event.error}]", file=sys.stderr)
192
+ elif isinstance(event, PermissionRequest):
193
+ print(f"[permission needed: {event.tool_name}]", file=sys.stderr)
194
+ elif isinstance(event, StreamDelta):
195
+ if event.text:
196
+ sys.stdout.write(event.text)
197
+ sys.stdout.flush()
198
+
199
+
200
+ def _print_json(event: Event) -> None:
201
+ """Print an event as a JSON line."""
202
+ import dataclasses
203
+ d = dataclasses.asdict(event)
204
+ print(json.dumps(d))
205
+
206
+
207
+ def main() -> None:
208
+ app.run()