agentshim 0.1.0__tar.gz

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.
Files changed (53) hide show
  1. agentshim-0.1.0/.github/workflows/ci.yml +18 -0
  2. agentshim-0.1.0/.github/workflows/publish.yml +41 -0
  3. agentshim-0.1.0/.gitignore +6 -0
  4. agentshim-0.1.0/PKG-INFO +61 -0
  5. agentshim-0.1.0/README.md +50 -0
  6. agentshim-0.1.0/agentshim/__init__.py +24 -0
  7. agentshim-0.1.0/agentshim/base.py +52 -0
  8. agentshim-0.1.0/agentshim/claude/__init__.py +3 -0
  9. agentshim-0.1.0/agentshim/claude/agent.py +264 -0
  10. agentshim-0.1.0/agentshim/claude/events.py +149 -0
  11. agentshim-0.1.0/agentshim/claude/hooks/__init__.py +0 -0
  12. agentshim-0.1.0/agentshim/claude/hooks/confine_reads.py +77 -0
  13. agentshim-0.1.0/agentshim/claude_events.py +21 -0
  14. agentshim-0.1.0/agentshim/cli_agent.py +433 -0
  15. agentshim-0.1.0/agentshim/codex/__init__.py +3 -0
  16. agentshim-0.1.0/agentshim/codex/agent.py +236 -0
  17. agentshim-0.1.0/agentshim/codex/events.py +216 -0
  18. agentshim-0.1.0/agentshim/codex_events.py +23 -0
  19. agentshim-0.1.0/agentshim/events.py +24 -0
  20. agentshim-0.1.0/agentshim/gemini/__init__.py +3 -0
  21. agentshim-0.1.0/agentshim/gemini/agent.py +234 -0
  22. agentshim-0.1.0/agentshim/gemini/events.py +78 -0
  23. agentshim-0.1.0/agentshim/gemini_events.py +11 -0
  24. agentshim-0.1.0/agentshim/llm_client.py +64 -0
  25. agentshim-0.1.0/agentshim/mcp_config.py +25 -0
  26. agentshim-0.1.0/agentshim/opencode/__init__.py +3 -0
  27. agentshim-0.1.0/agentshim/opencode/agent.py +196 -0
  28. agentshim-0.1.0/agentshim/opencode/events.py +90 -0
  29. agentshim-0.1.0/agentshim/opencode_events.py +11 -0
  30. agentshim-0.1.0/agentshim/py.typed +0 -0
  31. agentshim-0.1.0/agentshim/sandbox.py +133 -0
  32. agentshim-0.1.0/agentshim/subagent.py +93 -0
  33. agentshim-0.1.0/agentshim/trajectory.py +168 -0
  34. agentshim-0.1.0/agentshim/usage.py +63 -0
  35. agentshim-0.1.0/agentshim/utils.py +120 -0
  36. agentshim-0.1.0/pyproject.toml +44 -0
  37. agentshim-0.1.0/tests/unit/cli_agent/test_agent_cli_cleanup.py +558 -0
  38. agentshim-0.1.0/tests/unit/cli_agent/test_check_cli.py +38 -0
  39. agentshim-0.1.0/tests/unit/cli_agent/test_cli_prompt_passing.py +335 -0
  40. agentshim-0.1.0/tests/unit/llm/conftest.py +33 -0
  41. agentshim-0.1.0/tests/unit/llm/test_claude_stream.py +603 -0
  42. agentshim-0.1.0/tests/unit/llm/test_gemini_fixture.py +84 -0
  43. agentshim-0.1.0/tests/unit/llm/test_gemini_stream.py +186 -0
  44. agentshim-0.1.0/tests/unit/test_agent_cli_claude.py +252 -0
  45. agentshim-0.1.0/tests/unit/test_agent_cli_codex.py +69 -0
  46. agentshim-0.1.0/tests/unit/test_agent_cli_event_parsing.py +346 -0
  47. agentshim-0.1.0/tests/unit/test_agent_cli_mcp_unsupported.py +38 -0
  48. agentshim-0.1.0/tests/unit/test_agent_cli_resume.py +283 -0
  49. agentshim-0.1.0/tests/unit/test_agent_cli_sandbox.py +141 -0
  50. agentshim-0.1.0/tests/unit/test_cli_agent_usage.py +163 -0
  51. agentshim-0.1.0/tests/unit/test_coding_agent_recorder_default.py +12 -0
  52. agentshim-0.1.0/tests/unit/test_mcp_config.py +77 -0
  53. agentshim-0.1.0/uv.lock +2200 -0
@@ -0,0 +1,18 @@
1
+ name: CI
2
+
3
+ on:
4
+ push:
5
+ pull_request:
6
+
7
+ jobs:
8
+ test:
9
+ runs-on: ubuntu-latest
10
+ steps:
11
+ - uses: actions/checkout@v4
12
+ - uses: astral-sh/setup-uv@v6
13
+ - uses: actions/setup-python@v5
14
+ with:
15
+ python-version: "3.11"
16
+ - run: uv sync --dev
17
+ - run: uv run pytest
18
+ - run: uv run ruff check .
@@ -0,0 +1,41 @@
1
+ name: Publish
2
+
3
+ on:
4
+ workflow_dispatch:
5
+ release:
6
+ types: [published]
7
+
8
+ jobs:
9
+ build:
10
+ runs-on: ubuntu-latest
11
+ steps:
12
+ - uses: actions/checkout@v4
13
+ - uses: astral-sh/setup-uv@v6
14
+ - uses: actions/setup-python@v5
15
+ with:
16
+ python-version: "3.11"
17
+ - name: Build distributions
18
+ run: uv build
19
+ - name: Upload distributions
20
+ uses: actions/upload-artifact@v4
21
+ with:
22
+ name: python-package-distributions
23
+ path: dist/
24
+
25
+ publish:
26
+ needs: build
27
+ runs-on: ubuntu-latest
28
+ environment:
29
+ name: pypi
30
+ url: https://pypi.org/p/agentshim
31
+ permissions:
32
+ id-token: write
33
+ contents: read
34
+ steps:
35
+ - name: Download distributions
36
+ uses: actions/download-artifact@v4
37
+ with:
38
+ name: python-package-distributions
39
+ path: dist/
40
+ - name: Publish package distributions to PyPI
41
+ uses: pypa/gh-action-pypi-publish@release/v1
@@ -0,0 +1,6 @@
1
+ .venv/
2
+ __pycache__/
3
+ .pytest_cache/
4
+ .ruff_cache/
5
+ dist/
6
+ build/
@@ -0,0 +1,61 @@
1
+ Metadata-Version: 2.4
2
+ Name: agentshim
3
+ Version: 0.1.0
4
+ Summary: Provider-agnostic coding-agent CLI shims
5
+ Requires-Python: >=3.10
6
+ Requires-Dist: litellm>=1.0.0
7
+ Requires-Dist: loguru>=0.7.2
8
+ Provides-Extra: test
9
+ Requires-Dist: pytest>=8.0.0; extra == 'test'
10
+ Description-Content-Type: text/markdown
11
+
12
+ # agentshim
13
+
14
+ `agentshim` is a small Python package that wraps coding-agent CLIs behind a
15
+ stable interface for higher-level runtimes.
16
+
17
+ It currently includes:
18
+
19
+ - a `CodingAgent` base class and provider registry
20
+ - CLI adapters for Claude Code, Codex, Gemini, and Opencode
21
+ - MCP server config dataclasses
22
+ - sandbox settings helpers for Claude Code
23
+ - a lightweight litellm client and isolated subagent helper
24
+ - a trajectory protocol with a no-op implementation
25
+
26
+ ## Install
27
+
28
+ ```bash
29
+ pip install agentshim
30
+ ```
31
+
32
+ For development:
33
+
34
+ ```bash
35
+ uv sync --dev
36
+ uv run pytest
37
+ ```
38
+
39
+ ## Quick Example
40
+
41
+ ```python
42
+ from agentshim.claude import ClaudeCodeCodingAgent
43
+
44
+ agent = ClaudeCodeCodingAgent(model="sonnet")
45
+ result = agent.generate("Summarize the repository layout.", cwd=".")
46
+ print(result)
47
+ ```
48
+
49
+ ## Publishing
50
+
51
+ Build locally with:
52
+
53
+ ```bash
54
+ uv build
55
+ ```
56
+
57
+ Publish with:
58
+
59
+ ```bash
60
+ uv publish
61
+ ```
@@ -0,0 +1,50 @@
1
+ # agentshim
2
+
3
+ `agentshim` is a small Python package that wraps coding-agent CLIs behind a
4
+ stable interface for higher-level runtimes.
5
+
6
+ It currently includes:
7
+
8
+ - a `CodingAgent` base class and provider registry
9
+ - CLI adapters for Claude Code, Codex, Gemini, and Opencode
10
+ - MCP server config dataclasses
11
+ - sandbox settings helpers for Claude Code
12
+ - a lightweight litellm client and isolated subagent helper
13
+ - a trajectory protocol with a no-op implementation
14
+
15
+ ## Install
16
+
17
+ ```bash
18
+ pip install agentshim
19
+ ```
20
+
21
+ For development:
22
+
23
+ ```bash
24
+ uv sync --dev
25
+ uv run pytest
26
+ ```
27
+
28
+ ## Quick Example
29
+
30
+ ```python
31
+ from agentshim.claude import ClaudeCodeCodingAgent
32
+
33
+ agent = ClaudeCodeCodingAgent(model="sonnet")
34
+ result = agent.generate("Summarize the repository layout.", cwd=".")
35
+ print(result)
36
+ ```
37
+
38
+ ## Publishing
39
+
40
+ Build locally with:
41
+
42
+ ```bash
43
+ uv build
44
+ ```
45
+
46
+ Publish with:
47
+
48
+ ```bash
49
+ uv publish
50
+ ```
@@ -0,0 +1,24 @@
1
+ from .base import CodingAgent
2
+ from .claude import ClaudeCodeCodingAgent
3
+ from .codex import CodexCodingAgent
4
+ from .gemini import GeminiCodingAgent
5
+ from .llm_client import LiteLLMClient
6
+ from .mcp_config import HttpMcpServer, McpServerConfig, StdioMcpServer
7
+ from .opencode import OpencodeCodingAgent
8
+ from .sandbox import SandboxConfig
9
+ from .subagent import call_subagent, litellm_call_with_retry
10
+
11
+ __all__ = [
12
+ "CodingAgent",
13
+ "CodexCodingAgent",
14
+ "GeminiCodingAgent",
15
+ "OpencodeCodingAgent",
16
+ "ClaudeCodeCodingAgent",
17
+ "HttpMcpServer",
18
+ "McpServerConfig",
19
+ "StdioMcpServer",
20
+ "SandboxConfig",
21
+ "call_subagent",
22
+ "litellm_call_with_retry",
23
+ "LiteLLMClient",
24
+ ]
@@ -0,0 +1,52 @@
1
+ from abc import ABC, abstractmethod
2
+ from typing import Any, TypeVar
3
+
4
+ from agentshim.trajectory import NullTrajectoryRecorder, TrajectoryRecorderProtocol
5
+
6
+ _T = TypeVar("_T")
7
+
8
+ AGENT_REGISTRY: dict[str, Any] = {}
9
+
10
+
11
+ def register_provider(*names: str) -> Any:
12
+ """Decorator to register a coding agent provider.
13
+
14
+ Args:
15
+ *names: List of provider names/aliases (case-insensitive).
16
+ """
17
+
18
+ def decorator(cls: _T) -> _T:
19
+ for name in names:
20
+ AGENT_REGISTRY[name.lower()] = cls
21
+ return cls
22
+
23
+ return decorator
24
+
25
+
26
+ class CodingAgent(ABC):
27
+ """Abstract base class for coding agents."""
28
+
29
+ recorder: TrajectoryRecorderProtocol = NullTrajectoryRecorder()
30
+ event_handler: Any | None = None
31
+
32
+ @abstractmethod
33
+ def generate(
34
+ self,
35
+ prompt: str,
36
+ cwd: str | None = None,
37
+ timeout: int = 300,
38
+ silent: bool = False,
39
+ ) -> str:
40
+ """One-shot prompt → reply. Equivalent to a fresh ``start_session``
41
+ followed by a single ``session.generate(prompt)``; no conversation
42
+ state is retained. Use ``start_session`` for multi-turn flows.
43
+
44
+ Args:
45
+ prompt: The prompt to send to the agent.
46
+ cwd: Optional working directory context.
47
+ timeout: Timeout in seconds.
48
+ silent: If True, suppress stdout printing of the agent's output.
49
+
50
+ Returns:
51
+ Generated text.
52
+ """
@@ -0,0 +1,3 @@
1
+ from .agent import ClaudeCodeCodingAgent, ClaudeGenerationSession
2
+
3
+ __all__ = ["ClaudeCodeCodingAgent", "ClaudeGenerationSession"]
@@ -0,0 +1,264 @@
1
+ import json
2
+ import subprocess
3
+ import time
4
+ from collections.abc import Callable
5
+ from typing import Any
6
+
7
+ from agentshim.trajectory import TrajectoryRecorderProtocol
8
+
9
+ from ..base import register_provider
10
+ from ..cli_agent import CLICodingAgent, CLIGenerationSession
11
+ from ..events import AgentEventHandler
12
+ from ..mcp_config import HttpMcpServer, McpServerConfig
13
+ from ..sandbox import SandboxConfig, build_claude_sandbox_settings, resolve_sandbox
14
+ from ..usage import ProviderUsage, TokenUsage
15
+ from .events import (
16
+ ClaudeEvent,
17
+ MultiEvent,
18
+ ResultEvent,
19
+ SystemEvent,
20
+ TextEvent,
21
+ ToolResultEvent,
22
+ ToolUseEvent,
23
+ )
24
+
25
+
26
+ class ClaudeGenerationSession(CLIGenerationSession):
27
+ def __init__(self, **kwargs: Any):
28
+ super().__init__(**kwargs)
29
+ # Initialize state required for stream processing
30
+ self.tool_map: dict[str, str] = {}
31
+ self.tool_start_times: dict[str, float] = {}
32
+ self.tool_args: dict[str, Any] = {}
33
+ self.final_result: str | None = None
34
+
35
+ def _process_stdout(self, line: str) -> None:
36
+ """Process a line from stdout."""
37
+ if not line:
38
+ return
39
+ try:
40
+ data = json.loads(line)
41
+ event = ClaudeEvent.from_dict(data)
42
+ if event:
43
+ self._handle_event(event)
44
+ except json.JSONDecodeError:
45
+ # Fallback for non-JSON lines - still accumulate them
46
+ self.stdout_lines.append(line.rstrip())
47
+ if not self.silent:
48
+ if self._at_line_start:
49
+ self._log_raw(f"{self.log_prefix} ")
50
+ self._log_raw(line.rstrip() + "\n")
51
+ self._at_line_start = True
52
+
53
+ def _handle_event(self, event: ClaudeEvent):
54
+ """Handle a single parsed Claude event."""
55
+ if isinstance(event, MultiEvent):
56
+ for sub_event in event.events:
57
+ self._handle_event(sub_event)
58
+ return
59
+
60
+ self._update_state(event)
61
+
62
+ if not self.silent:
63
+ self._render_event(event)
64
+
65
+ def _update_state(self, event: ClaudeEvent):
66
+ """Update internal state based on the event."""
67
+ if isinstance(event, SystemEvent):
68
+ if self.session_id is None and event.session_id:
69
+ self.session_id = event.session_id
70
+ return
71
+
72
+ if isinstance(event, TextEvent):
73
+ self.stdout_lines.append(event.text)
74
+ if self.event_handler:
75
+ self.event_handler.on_thinking(event.text)
76
+
77
+ elif isinstance(event, ToolUseEvent):
78
+ if event.tool_id:
79
+ self.tool_map[event.tool_id] = event.tool_name
80
+ self.tool_start_times[event.tool_id] = time.time()
81
+ self.tool_args[event.tool_id] = event.parameters
82
+ if self.event_handler:
83
+ self.event_handler.on_tool_call(event.tool_name, event.parameters)
84
+
85
+ elif isinstance(event, ToolResultEvent):
86
+ if event.tool_id:
87
+ event.tool_name_resolved = self.tool_map.get(event.tool_id, "Tool")
88
+
89
+ start_time = self.tool_start_times.get(event.tool_id)
90
+ duration = time.time() - start_time if start_time else None
91
+ args = self.tool_args.get(event.tool_id, {})
92
+
93
+ self.recorder.add_tool_call(
94
+ tool=event.tool_name_resolved,
95
+ args=args,
96
+ stdout=event.output,
97
+ duration=duration,
98
+ )
99
+ if self.event_handler:
100
+ self.event_handler.on_tool_result(
101
+ tool=event.tool_name_resolved,
102
+ stdout=event.output,
103
+ duration=duration,
104
+ )
105
+
106
+ elif isinstance(event, ResultEvent):
107
+ self.final_result = event.result
108
+ # Anthropic reports cache_creation + cache_read as disjoint
109
+ # from input_tokens; fold them into input_tokens to match the
110
+ # crucible invariant (cached ⊆ input).
111
+ usage = event.usage or {}
112
+ cached = int(usage.get("cache_creation_input_tokens") or 0) + int(usage.get("cache_read_input_tokens") or 0)
113
+ self.usage = ProviderUsage(
114
+ tokens=TokenUsage(
115
+ input_tokens=int(usage.get("input_tokens") or 0) + cached,
116
+ output_tokens=int(usage.get("output_tokens") or 0),
117
+ cached_input_tokens=cached,
118
+ turns=int(event.num_turns or 0),
119
+ ),
120
+ total_cost_usd=event.total_cost_usd,
121
+ provider="claude",
122
+ )
123
+
124
+ def _render_event(self, event: ClaudeEvent):
125
+ """Render the event to stdout."""
126
+ if isinstance(event, TextEvent):
127
+ self._print_stream_content(event.text)
128
+ return
129
+
130
+ if not self._at_line_start:
131
+ self._log_raw("\n")
132
+ self._at_line_start = True
133
+
134
+ output = event.render(self.log_prefix)
135
+ if output:
136
+ self._log_raw(output + "\n")
137
+
138
+ def run(self, prompt: str) -> str:
139
+ """Execute the command and return the result."""
140
+ super().run(prompt)
141
+ if self.final_result:
142
+ return self.final_result
143
+ return "\n".join(self.stdout_lines)
144
+
145
+
146
+ @register_provider("claude", "claude-code", "anthropic")
147
+ class ClaudeCodeCodingAgent(CLICodingAgent):
148
+ """Coding agent implementation using the Claude Code CLI tool."""
149
+
150
+ def __init__(
151
+ self,
152
+ model: str | None = None,
153
+ recorder: TrajectoryRecorderProtocol | None = None,
154
+ event_handler: AgentEventHandler | None = None,
155
+ mcp_servers: list[McpServerConfig] | None = None,
156
+ sandbox: bool | SandboxConfig = False,
157
+ ):
158
+ """Initialize the Claude Code coding agent.
159
+
160
+ Args:
161
+ model: Optional model name to use with Claude Code. If None, uses default.
162
+ recorder: Trajectory recorder instance.
163
+ event_handler: Optional event handler for UI updates.
164
+ mcp_servers: Optional list of MCP server configurations.
165
+ sandbox: If True (or a ``SandboxConfig``), enable Claude Code's
166
+ native sandbox (bubblewrap on Linux / Seatbelt on macOS)
167
+ by injecting a ``sandbox`` settings block via
168
+ ``--settings``. Only bash subprocess commands are
169
+ sandboxed; the Claude process itself is not wrapped.
170
+ Defaults to False (no sandbox).
171
+ """
172
+ super().__init__("claude", model, recorder, event_handler, mcp_servers)
173
+ self.sandbox = resolve_sandbox(sandbox)
174
+ if self.sandbox is not None:
175
+ # Without this, Claude Code cd's into a per-invocation scratch dir
176
+ # under <project-root>/.local_tmp/claude-$UID/cwd-* before every
177
+ # Bash call. That dir is outside the sandbox's allow_write set, so
178
+ # every sandboxed Bash invocation fails with EROFS before its
179
+ # command runs. Maintaining the project cwd avoids the scratch dir.
180
+ self.env["CLAUDE_BASH_MAINTAIN_PROJECT_WORKING_DIR"] = "1"
181
+
182
+ @property
183
+ def claude_path(self) -> str:
184
+ """Return path to claude binary (for backward compatibility)."""
185
+ return self.binary_path
186
+
187
+ @property
188
+ def _log_prefix(self) -> str:
189
+ """Return the log prefix for this agent."""
190
+ return "[Claude]"
191
+
192
+ def _build_mcp_config_json(self) -> str:
193
+ """Build the JSON string for --mcp-config.
194
+
195
+ Claude Code runs the rendered config through ``--strict-mcp-config``
196
+ validation, which requires HTTP servers to declare ``type``
197
+ explicitly (``"sse"`` or ``"http"``). ``HttpMcpServer`` represents
198
+ the SSE transport (the field doc says HTTP/SSE; current call sites
199
+ use ``…/sse`` URLs), so emit ``type: "sse"``. ``headers`` is
200
+ included only when non-empty, mirroring the schema's optional
201
+ nature.
202
+ """
203
+ servers: dict[str, dict[str, Any]] = {}
204
+ for server in self.mcp_servers:
205
+ if isinstance(server, HttpMcpServer):
206
+ http_entry: dict[str, Any] = {"type": "sse", "url": server.url}
207
+ if server.headers:
208
+ http_entry["headers"] = dict(server.headers)
209
+ servers[server.name] = http_entry
210
+ else:
211
+ entry: dict[str, Any] = {"command": server.command, "args": server.args}
212
+ if server.env:
213
+ entry["env"] = server.env
214
+ servers[server.name] = entry
215
+ return json.dumps({"mcpServers": servers})
216
+
217
+ def _get_command(self, prompt: str, resume_session_id: str | None = None) -> list[str]:
218
+ cmd = [
219
+ self.binary_path,
220
+ "-p", # Print mode, accepts prompt from stdin
221
+ "--dangerously-skip-permissions", # Auto-approval mode
222
+ "--output-format",
223
+ "stream-json",
224
+ "--verbose",
225
+ ]
226
+ if resume_session_id:
227
+ cmd.extend(["--resume", resume_session_id])
228
+ cmd.append(prompt)
229
+ if self.model:
230
+ cmd.extend(["--model", self.model])
231
+ if self.mcp_servers:
232
+ cmd.extend(
233
+ [
234
+ "--mcp-config",
235
+ self._build_mcp_config_json(),
236
+ "--strict-mcp-config",
237
+ ]
238
+ )
239
+ if self.sandbox is not None:
240
+ cmd.extend(["--settings", json.dumps(build_claude_sandbox_settings(self.sandbox))])
241
+ return cmd
242
+
243
+ def _create_session(
244
+ self,
245
+ cmd: list[str],
246
+ cwd: str | None = None,
247
+ timeout: int = 300,
248
+ silent: bool = False,
249
+ recorder: TrajectoryRecorderProtocol | None = None,
250
+ on_process_started: Callable[[subprocess.Popen[str]], None] | None = None,
251
+ ) -> ClaudeGenerationSession:
252
+ return ClaudeGenerationSession(
253
+ binary_name=self.binary_name,
254
+ env=self.env,
255
+ log_prefix=self._log_prefix,
256
+ cmd=cmd,
257
+ logger=self.logger,
258
+ cwd=cwd,
259
+ timeout=timeout,
260
+ silent=silent,
261
+ recorder=recorder,
262
+ event_handler=self.event_handler,
263
+ on_process_started=on_process_started,
264
+ )
@@ -0,0 +1,149 @@
1
+ from __future__ import annotations
2
+
3
+ from abc import ABC, abstractmethod
4
+ from typing import Any, cast
5
+
6
+ from ..utils import truncate_content, truncate_tool_params
7
+
8
+
9
+ class ClaudeEvent(ABC):
10
+ """Base class for Claude Code stream events."""
11
+
12
+ @abstractmethod
13
+ def render(self, log_prefix: str) -> str | None:
14
+ """Render the event as a string for terminal output."""
15
+
16
+ @staticmethod
17
+ def from_dict(data: dict[str, Any]) -> ClaudeEvent | None:
18
+ """Factory method to create events from JSON data."""
19
+ event_type = data.get("type")
20
+
21
+ if event_type == "system":
22
+ return SystemEvent(data)
23
+ if event_type == "assistant":
24
+ message = data.get("message", {})
25
+ content_blocks = message.get("content", [])
26
+ events: list[ClaudeEvent] = []
27
+ for block in content_blocks:
28
+ block_type = block.get("type")
29
+ if block_type == "text":
30
+ events.append(TextEvent(block.get("text", "")))
31
+ elif block_type == "tool_use":
32
+ events.append(
33
+ ToolUseEvent(
34
+ tool_name=block.get("name", "Tool"),
35
+ tool_id=block.get("id"),
36
+ parameters=block.get("input"),
37
+ )
38
+ )
39
+ return MultiEvent(events) if events else None
40
+ if event_type == "user":
41
+ message = data.get("message", {})
42
+ content_blocks = message.get("content", [])
43
+ for block in content_blocks:
44
+ if block.get("type") == "tool_result":
45
+ return ToolResultEvent(
46
+ output=block.get("content", ""),
47
+ tool_id=block.get("tool_use_id"),
48
+ )
49
+ return None
50
+ if event_type == "result":
51
+ return ResultEvent(
52
+ result=data.get("result", ""),
53
+ num_turns=data.get("num_turns"),
54
+ usage=data.get("usage"),
55
+ total_cost_usd=data.get("total_cost_usd"),
56
+ )
57
+
58
+ return None
59
+
60
+
61
+ class MultiEvent(ClaudeEvent):
62
+ """Container for multiple events from a single message."""
63
+
64
+ def __init__(self, events: list[ClaudeEvent]):
65
+ self.events = events
66
+
67
+ def render(self, log_prefix: str) -> str | None:
68
+ # MultiEvent doesn't render itself; events are handled individually
69
+ return None
70
+
71
+
72
+ class SystemEvent(ClaudeEvent):
73
+ """System initialization event.
74
+
75
+ Carries the provider ``session_id`` on the ``init`` subtype, used to
76
+ enable conversation resumption via ``claude --resume <id>``.
77
+ """
78
+
79
+ def __init__(self, data: dict[str, Any]):
80
+ self.data = data
81
+ self.session_id: str | None = data.get("session_id")
82
+
83
+ def render(self, log_prefix: str) -> str | None:
84
+ # System events are silent
85
+ return None
86
+
87
+
88
+ class TextEvent(ClaudeEvent):
89
+ """Assistant text content event."""
90
+
91
+ def __init__(self, text: str):
92
+ self.text = text
93
+
94
+ def render(self, log_prefix: str) -> str | None:
95
+ # Text rendering is handled specially due to streaming
96
+ return self.text
97
+
98
+
99
+ class ToolUseEvent(ClaudeEvent):
100
+ """Tool call event from assistant."""
101
+
102
+ def __init__(self, tool_name: str, tool_id: str | None, parameters: Any):
103
+ self.tool_name = tool_name
104
+ self.tool_id = tool_id
105
+ self.parameters = parameters
106
+
107
+ def render(self, log_prefix: str) -> str:
108
+ truncated = truncate_tool_params(self.tool_name, self.parameters)
109
+ return f"{log_prefix} \033[34m[Tool Use] {self.tool_name} {truncated}\033[0m"
110
+
111
+
112
+ class ToolResultEvent(ClaudeEvent):
113
+ """Tool execution result event."""
114
+
115
+ def __init__(self, output: Any, tool_id: str | None):
116
+ # Convert output to string if it's not already
117
+ if isinstance(output, list):
118
+ # Handle list content (e.g., from tool_result blocks with multiple items)
119
+ self.output = "\n".join(str(item) for item in cast("list[Any]", output))
120
+ else:
121
+ self.output = str(output) if output else ""
122
+ self.tool_id = tool_id
123
+ self.tool_name_resolved: str = "Tool" # To be set externally
124
+
125
+ def render(self, log_prefix: str) -> str:
126
+ if not self.output:
127
+ return f"{log_prefix} \033[32m{self.tool_name_resolved} ran successfully\033[0m"
128
+ truncated = truncate_content(self.output)
129
+ return f"{log_prefix} \033[32m[Tool Result] {truncated}\033[0m"
130
+
131
+
132
+ class ResultEvent(ClaudeEvent):
133
+ """Final session summary event."""
134
+
135
+ def __init__(
136
+ self,
137
+ result: str,
138
+ num_turns: int | None = None,
139
+ usage: dict[str, Any] | None = None,
140
+ total_cost_usd: float | None = None,
141
+ ):
142
+ self.result = result
143
+ self.num_turns = num_turns
144
+ self.usage = usage
145
+ self.total_cost_usd = total_cost_usd
146
+
147
+ def render(self, log_prefix: str) -> str | None:
148
+ # Result events are silent (result is captured separately)
149
+ return None
File without changes