ripperdoc 0.2.10__py3-none-any.whl → 0.3.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.
Files changed (70) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +164 -57
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +3 -7
  5. ripperdoc/cli/commands/doctor_cmd.py +29 -0
  6. ripperdoc/cli/commands/memory_cmd.py +2 -1
  7. ripperdoc/cli/commands/models_cmd.py +61 -5
  8. ripperdoc/cli/commands/resume_cmd.py +1 -0
  9. ripperdoc/cli/commands/skills_cmd.py +103 -0
  10. ripperdoc/cli/commands/stats_cmd.py +4 -4
  11. ripperdoc/cli/commands/status_cmd.py +10 -0
  12. ripperdoc/cli/commands/tasks_cmd.py +6 -3
  13. ripperdoc/cli/commands/themes_cmd.py +139 -0
  14. ripperdoc/cli/ui/file_mention_completer.py +63 -13
  15. ripperdoc/cli/ui/helpers.py +6 -3
  16. ripperdoc/cli/ui/interrupt_handler.py +34 -0
  17. ripperdoc/cli/ui/panels.py +13 -8
  18. ripperdoc/cli/ui/rich_ui.py +451 -32
  19. ripperdoc/cli/ui/spinner.py +68 -5
  20. ripperdoc/cli/ui/tool_renderers.py +10 -9
  21. ripperdoc/cli/ui/wizard.py +18 -11
  22. ripperdoc/core/agents.py +4 -0
  23. ripperdoc/core/config.py +235 -0
  24. ripperdoc/core/default_tools.py +1 -0
  25. ripperdoc/core/hooks/llm_callback.py +0 -1
  26. ripperdoc/core/hooks/manager.py +6 -0
  27. ripperdoc/core/permissions.py +82 -5
  28. ripperdoc/core/providers/openai.py +55 -9
  29. ripperdoc/core/query.py +349 -108
  30. ripperdoc/core/query_utils.py +17 -14
  31. ripperdoc/core/skills.py +1 -0
  32. ripperdoc/core/theme.py +298 -0
  33. ripperdoc/core/tool.py +8 -3
  34. ripperdoc/protocol/__init__.py +14 -0
  35. ripperdoc/protocol/models.py +300 -0
  36. ripperdoc/protocol/stdio.py +1453 -0
  37. ripperdoc/tools/background_shell.py +49 -5
  38. ripperdoc/tools/bash_tool.py +75 -9
  39. ripperdoc/tools/file_edit_tool.py +98 -29
  40. ripperdoc/tools/file_read_tool.py +139 -8
  41. ripperdoc/tools/file_write_tool.py +46 -3
  42. ripperdoc/tools/grep_tool.py +98 -8
  43. ripperdoc/tools/lsp_tool.py +9 -15
  44. ripperdoc/tools/multi_edit_tool.py +26 -3
  45. ripperdoc/tools/skill_tool.py +52 -1
  46. ripperdoc/tools/task_tool.py +33 -8
  47. ripperdoc/utils/file_watch.py +12 -6
  48. ripperdoc/utils/image_utils.py +125 -0
  49. ripperdoc/utils/log.py +30 -3
  50. ripperdoc/utils/lsp.py +9 -3
  51. ripperdoc/utils/mcp.py +80 -18
  52. ripperdoc/utils/message_formatting.py +2 -2
  53. ripperdoc/utils/messages.py +177 -32
  54. ripperdoc/utils/pending_messages.py +50 -0
  55. ripperdoc/utils/permissions/shell_command_validation.py +3 -3
  56. ripperdoc/utils/permissions/tool_permission_utils.py +9 -3
  57. ripperdoc/utils/platform.py +198 -0
  58. ripperdoc/utils/session_heatmap.py +1 -3
  59. ripperdoc/utils/session_history.py +2 -2
  60. ripperdoc/utils/session_stats.py +1 -0
  61. ripperdoc/utils/shell_utils.py +8 -5
  62. ripperdoc/utils/todo.py +0 -6
  63. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/METADATA +49 -17
  64. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/RECORD +68 -61
  65. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/WHEEL +1 -1
  66. ripperdoc/sdk/__init__.py +0 -9
  67. ripperdoc/sdk/client.py +0 -408
  68. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/entry_points.txt +0 -0
  69. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/licenses/LICENSE +0 -0
  70. {ripperdoc-0.2.10.dist-info → ripperdoc-0.3.0.dist-info}/top_level.txt +0 -0
@@ -125,20 +125,23 @@ def estimate_cost_usd(model_profile: ModelProfile, usage_tokens: Dict[str, int])
125
125
 
126
126
 
127
127
  def resolve_model_profile(model: str) -> ModelProfile:
128
- """Resolve a model pointer to a concrete profile, falling back to a safe default."""
129
- config = get_global_config()
130
- profile_name = getattr(config.model_pointers, model, None) or model
131
- model_profile = config.model_profiles.get(profile_name)
132
- if model_profile is None:
133
- fallback_profile = getattr(config.model_pointers, "main", "default")
134
- model_profile = config.model_profiles.get(fallback_profile)
135
- if not model_profile:
136
- logger.warning(
137
- "[config] No model profile found; using built-in default profile",
138
- extra={"model_pointer": model},
139
- )
140
- return ModelProfile(provider=ProviderType.OPENAI_COMPATIBLE, model="gpt-4o-mini")
141
- return model_profile
128
+ """Resolve a model pointer to a concrete profile, falling back to a safe default.
129
+
130
+ 此函数现在尊重 RIPPERDOC_* 环境变量的覆盖。
131
+ """
132
+ from ripperdoc.core.config import get_effective_model_profile
133
+
134
+ # 首先尝试使用 get_effective_model_profile 来应用环境变量覆盖
135
+ model_profile = get_effective_model_profile(model)
136
+ if model_profile:
137
+ return model_profile
138
+
139
+ # 如果仍然没有找到,使用默认配置
140
+ logger.warning(
141
+ "[config] No model profile found; using built-in default profile",
142
+ extra={"model_pointer": model},
143
+ )
144
+ return ModelProfile(provider=ProviderType.OPENAI_COMPATIBLE, model="gpt-4o-mini")
142
145
 
143
146
 
144
147
  def determine_tool_mode(model_profile: ModelProfile) -> str:
ripperdoc/core/skills.py CHANGED
@@ -278,6 +278,7 @@ def build_skill_summary(skills: Sequence[SkillDefinition]) -> str:
278
278
  lines = [
279
279
  "# Skills",
280
280
  "Skills extend your capabilities with reusable instructions stored in SKILL.md files.",
281
+ "IMPORTANT: To use a skill, you MUST call the Skill tool. Do NOT read SKILL.md files directly with Read, Glob, Grep, or Bash commands.",
281
282
  'Call the Skill tool with {"skill": "<name>"} to load a skill when it matches the user request.',
282
283
  "Available skills:",
283
284
  ]
@@ -0,0 +1,298 @@
1
+ """Theme system for Ripperdoc CLI.
2
+
3
+ Provides color theming support with predefined themes and runtime switching.
4
+ """
5
+
6
+ from dataclasses import dataclass, field
7
+ from typing import Callable, Dict, List, Optional
8
+
9
+
10
+ @dataclass
11
+ class ThemeColors:
12
+ """Theme color definitions - all semantic color slots."""
13
+
14
+ # === Brand/Primary colors ===
15
+ primary: str = "cyan" # Main accent (brand, borders, spinner)
16
+ secondary: str = "green" # Secondary accent (success, ready status)
17
+
18
+ # === Semantic colors ===
19
+ success: str = "green"
20
+ warning: str = "yellow"
21
+ error: str = "red"
22
+ info: str = "blue"
23
+
24
+ # === Text colors ===
25
+ text_primary: str = "white"
26
+ text_secondary: str = "dim" # dim/grey for secondary text
27
+ text_muted: str = "grey50"
28
+
29
+ # === UI elements ===
30
+ border: str = "cyan"
31
+ spinner: str = "cyan"
32
+ prompt: str = "bold green"
33
+
34
+ # === Tool output ===
35
+ tool_call: str = "dim cyan"
36
+ tool_result: str = "dim"
37
+
38
+ # === Message senders ===
39
+ sender_user: str = "bold green"
40
+ sender_assistant: str = "white"
41
+ sender_system: str = "dim cyan"
42
+
43
+ # === Context visualization ===
44
+ ctx_system_prompt: str = "grey58"
45
+ ctx_mcp: str = "cyan"
46
+ ctx_tools: str = "green3"
47
+ ctx_memory: str = "dark_orange3"
48
+ ctx_messages: str = "medium_purple"
49
+ ctx_free: str = "grey46"
50
+ ctx_reserved: str = "yellow3"
51
+
52
+ # === Thinking mode ===
53
+ thinking_on: str = "ansicyan bold"
54
+ thinking_off: str = "ansibrightblack"
55
+ thinking_content: str = "dim italic"
56
+
57
+ # === Headings and emphasis ===
58
+ heading: str = "bold"
59
+ emphasis: str = "bold cyan"
60
+
61
+
62
+ @dataclass
63
+ class Theme:
64
+ """Complete theme definition."""
65
+
66
+ name: str
67
+ display_name: str
68
+ description: str
69
+ colors: ThemeColors = field(default_factory=ThemeColors)
70
+
71
+ def get_color(self, slot: str) -> str:
72
+ """Get color for a specific slot."""
73
+ return getattr(self.colors, slot, "white")
74
+
75
+
76
+ # === Predefined themes ===
77
+
78
+ THEME_DARK = Theme(
79
+ name="dark",
80
+ display_name="Dark",
81
+ description="Default dark theme with cyan accents",
82
+ colors=ThemeColors(),
83
+ )
84
+
85
+ THEME_LIGHT = Theme(
86
+ name="light",
87
+ display_name="Light",
88
+ description="Light theme for bright terminals",
89
+ colors=ThemeColors(
90
+ primary="blue",
91
+ secondary="green",
92
+ text_primary="black",
93
+ text_secondary="grey37",
94
+ text_muted="grey50",
95
+ border="blue",
96
+ spinner="blue",
97
+ tool_call="dim blue",
98
+ sender_user="bold blue",
99
+ ctx_system_prompt="grey37",
100
+ ctx_free="grey62",
101
+ thinking_on="ansiblue bold",
102
+ thinking_off="grey50",
103
+ emphasis="bold blue",
104
+ ),
105
+ )
106
+
107
+ THEME_MONOKAI = Theme(
108
+ name="monokai",
109
+ display_name="Monokai",
110
+ description="Monokai-inspired color scheme",
111
+ colors=ThemeColors(
112
+ primary="#f92672", # Monokai pink
113
+ secondary="#a6e22e", # Monokai green
114
+ warning="#e6db74", # Monokai yellow
115
+ error="#f92672", # Monokai pink
116
+ info="#66d9ef", # Monokai cyan
117
+ border="#f92672",
118
+ spinner="#66d9ef",
119
+ tool_call="#75715e", # Monokai comment
120
+ sender_user="#a6e22e",
121
+ emphasis="#f92672",
122
+ ctx_mcp="#66d9ef",
123
+ ctx_tools="#a6e22e",
124
+ ctx_memory="#fd971f", # Monokai orange
125
+ ctx_messages="#ae81ff", # Monokai purple
126
+ ),
127
+ )
128
+
129
+ THEME_DRACULA = Theme(
130
+ name="dracula",
131
+ display_name="Dracula",
132
+ description="Dracula color scheme",
133
+ colors=ThemeColors(
134
+ primary="#bd93f9", # Dracula purple
135
+ secondary="#50fa7b", # Dracula green
136
+ warning="#f1fa8c", # Dracula yellow
137
+ error="#ff5555", # Dracula red
138
+ info="#8be9fd", # Dracula cyan
139
+ border="#bd93f9",
140
+ spinner="#bd93f9",
141
+ tool_call="#6272a4", # Dracula comment
142
+ sender_user="#50fa7b",
143
+ emphasis="#ff79c6", # Dracula pink
144
+ ctx_mcp="#8be9fd",
145
+ ctx_tools="#50fa7b",
146
+ ctx_memory="#ffb86c", # Dracula orange
147
+ ctx_messages="#bd93f9",
148
+ ),
149
+ )
150
+
151
+ THEME_SOLARIZED_DARK = Theme(
152
+ name="solarized_dark",
153
+ display_name="Solarized Dark",
154
+ description="Solarized dark color scheme",
155
+ colors=ThemeColors(
156
+ primary="#268bd2", # Solarized blue
157
+ secondary="#859900", # Solarized green
158
+ warning="#b58900", # Solarized yellow
159
+ error="#dc322f", # Solarized red
160
+ info="#2aa198", # Solarized cyan
161
+ text_secondary="#586e75", # Solarized base01
162
+ border="#268bd2",
163
+ spinner="#2aa198",
164
+ emphasis="#268bd2",
165
+ ctx_mcp="#2aa198",
166
+ ctx_tools="#859900",
167
+ ctx_memory="#cb4b16", # Solarized orange
168
+ ctx_messages="#6c71c4", # Solarized violet
169
+ ),
170
+ )
171
+
172
+ THEME_NORD = Theme(
173
+ name="nord",
174
+ display_name="Nord",
175
+ description="Arctic, bluish color scheme",
176
+ colors=ThemeColors(
177
+ primary="#88c0d0", # Nord frost
178
+ secondary="#a3be8c", # Nord green
179
+ warning="#ebcb8b", # Nord yellow
180
+ error="#bf616a", # Nord red
181
+ info="#81a1c1", # Nord blue
182
+ text_secondary="#4c566a", # Nord polar night
183
+ border="#88c0d0",
184
+ spinner="#88c0d0",
185
+ emphasis="#88c0d0",
186
+ ctx_mcp="#81a1c1",
187
+ ctx_tools="#a3be8c",
188
+ ctx_memory="#d08770", # Nord orange
189
+ ctx_messages="#b48ead", # Nord purple
190
+ ),
191
+ )
192
+
193
+ # Theme registry
194
+ BUILTIN_THEMES: Dict[str, Theme] = {
195
+ "dark": THEME_DARK,
196
+ "light": THEME_LIGHT,
197
+ "monokai": THEME_MONOKAI,
198
+ "dracula": THEME_DRACULA,
199
+ "solarized_dark": THEME_SOLARIZED_DARK,
200
+ "nord": THEME_NORD,
201
+ }
202
+
203
+
204
+ class ThemeManager:
205
+ """Theme manager - singleton pattern."""
206
+
207
+ _instance: Optional["ThemeManager"] = None
208
+ _current_theme: Theme
209
+ _listeners: List[Callable[["Theme"], None]]
210
+
211
+ def __new__(cls) -> "ThemeManager":
212
+ if cls._instance is None:
213
+ cls._instance = super().__new__(cls)
214
+ cls._instance._current_theme = THEME_DARK
215
+ cls._instance._listeners = []
216
+ return cls._instance
217
+
218
+ @property
219
+ def current(self) -> Theme:
220
+ """Get current theme."""
221
+ return self._current_theme
222
+
223
+ def set_theme(self, theme_name: str) -> bool:
224
+ """Set theme by name (does not persist)."""
225
+ theme = BUILTIN_THEMES.get(theme_name)
226
+ if theme:
227
+ self._current_theme = theme
228
+ self._notify_listeners()
229
+ return True
230
+ return False
231
+
232
+ def get_color(self, slot: str) -> str:
233
+ """Get color for a slot from current theme."""
234
+ return self._current_theme.get_color(slot)
235
+
236
+ def add_listener(self, callback: Callable[["Theme"], None]) -> None:
237
+ """Add a theme change listener."""
238
+ self._listeners.append(callback)
239
+
240
+ def remove_listener(self, callback: Callable[["Theme"], None]) -> None:
241
+ """Remove a theme change listener."""
242
+ if callback in self._listeners:
243
+ self._listeners.remove(callback)
244
+
245
+ def _notify_listeners(self) -> None:
246
+ """Notify all listeners of theme change."""
247
+ for listener in self._listeners:
248
+ try:
249
+ listener(self._current_theme)
250
+ except Exception:
251
+ pass # Don't let listener errors break theme switching
252
+
253
+ def list_themes(self) -> List[str]:
254
+ """List all available theme names."""
255
+ return list(BUILTIN_THEMES.keys())
256
+
257
+
258
+ # Global accessor functions
259
+ _theme_manager: Optional[ThemeManager] = None
260
+
261
+
262
+ def get_theme_manager() -> ThemeManager:
263
+ """Get the global theme manager instance."""
264
+ global _theme_manager
265
+ if _theme_manager is None:
266
+ _theme_manager = ThemeManager()
267
+ return _theme_manager
268
+
269
+
270
+ def theme_color(slot: str) -> str:
271
+ """Convenience function: get color for a slot from current theme."""
272
+ return get_theme_manager().get_color(slot)
273
+
274
+
275
+ def styled(text: str, slot: str) -> str:
276
+ """Convenience function: wrap text with theme color markup."""
277
+ color = theme_color(slot)
278
+ # Handle colors that already include style modifiers (like "bold cyan")
279
+ if " " in color:
280
+ return f"[{color}]{text}[/]"
281
+ return f"[{color}]{text}[/{color}]"
282
+
283
+
284
+ def get_current_theme() -> Theme:
285
+ """Get the current active theme."""
286
+ return get_theme_manager().current
287
+
288
+
289
+ __all__ = [
290
+ "Theme",
291
+ "ThemeColors",
292
+ "ThemeManager",
293
+ "BUILTIN_THEMES",
294
+ "get_theme_manager",
295
+ "theme_color",
296
+ "styled",
297
+ "get_current_theme",
298
+ ]
ripperdoc/core/tool.py CHANGED
@@ -10,6 +10,7 @@ from typing import Annotated, Any, AsyncGenerator, Dict, List, Optional, TypeVar
10
10
  from pydantic import BaseModel, ConfigDict, Field, SkipValidation
11
11
  from ripperdoc.utils.file_watch import FileCacheType
12
12
  from ripperdoc.utils.log import get_logger
13
+ from ripperdoc.utils.pending_messages import PendingMessageQueue
13
14
 
14
15
 
15
16
  logger = get_logger()
@@ -30,6 +31,8 @@ class ToolProgress(BaseModel):
30
31
  content: Any
31
32
  normalized_messages: list = []
32
33
  tools: list = []
34
+ # Flag to indicate if content is a subagent message that should be forwarded to SDK
35
+ is_subagent_message: bool = False
33
36
 
34
37
 
35
38
  class ToolUseContext(BaseModel):
@@ -44,9 +47,7 @@ class ToolUseContext(BaseModel):
44
47
  # SkipValidation prevents Pydantic from copying the cache during validation,
45
48
  # ensuring Read and Edit tools share the same cache instance.
46
49
  # FileCacheType supports both Dict[str, FileSnapshot] and BoundedFileCache.
47
- file_state_cache: Annotated[FileCacheType, SkipValidation] = Field(
48
- default_factory=dict
49
- )
50
+ file_state_cache: Annotated[FileCacheType, SkipValidation] = Field(default_factory=dict)
50
51
  conversation_messages: Annotated[Optional[List[Any]], SkipValidation] = Field(
51
52
  default=None,
52
53
  description="Full conversation history for tools that need parent context.",
@@ -60,6 +61,10 @@ class ToolUseContext(BaseModel):
60
61
  on_exit_plan_mode: Optional[Any] = Field(
61
62
  default=None, description="Callback invoked when exiting plan mode"
62
63
  )
64
+ pending_message_queue: Annotated[Optional[PendingMessageQueue], SkipValidation] = Field(
65
+ default=None,
66
+ description="Queue for pending conversation messages (background notices or user interjections).",
67
+ )
63
68
  model_config = ConfigDict(arbitrary_types_allowed=True)
64
69
 
65
70
 
@@ -0,0 +1,14 @@
1
+ """SDK communication protocol modules.
2
+
3
+ This package contains protocol handlers for communication between
4
+ Ripperdoc CLI and external SDKs.
5
+ """
6
+
7
+ from ripperdoc.protocol.stdio import stdio_cmd, StdioProtocolHandler
8
+ from ripperdoc.protocol import models
9
+
10
+ __all__ = [
11
+ "stdio_cmd",
12
+ "StdioProtocolHandler",
13
+ "models",
14
+ ]
@@ -0,0 +1,300 @@
1
+ """Pydantic models for stdio protocol messages.
2
+
3
+ This module defines type-safe models for all JSON messages exchanged
4
+ over the stdio protocol, replacing raw dictionary construction with
5
+ validated, self-documenting Pydantic models.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ from typing import Any, Literal
11
+ from pydantic import BaseModel, Field, ConfigDict
12
+
13
+
14
+ # ============================================================================
15
+ # Content Block Models
16
+ # ============================================================================
17
+
18
+
19
+ class ContentBlock(BaseModel):
20
+ """Base class for content blocks in messages."""
21
+
22
+ model_config = ConfigDict(
23
+ extra="allow",
24
+ populate_by_name=True,
25
+ )
26
+
27
+
28
+ class TextContentBlock(ContentBlock):
29
+ """A text content block."""
30
+
31
+ type: Literal["text"] = "text"
32
+ text: str
33
+
34
+
35
+ class ThinkingContentBlock(ContentBlock):
36
+ """A thinking/reasoning content block."""
37
+
38
+ type: str = Field(default="thinking")
39
+ thinking: str = Field(alias="text")
40
+ signature: str | None = None
41
+
42
+
43
+ class ToolUseContentBlock(ContentBlock):
44
+ """A tool use content block."""
45
+
46
+ type: str = Field(default="tool_use")
47
+ id: str = Field(default="")
48
+ name: str
49
+ input: dict[str, Any] = Field(default_factory=dict)
50
+
51
+
52
+ class ToolResultContentBlock(ContentBlock):
53
+ """A tool result content block."""
54
+
55
+ type: str = Field(default="tool_result")
56
+ tool_use_id: str = Field(default="")
57
+ content: str = Field(default="")
58
+ is_error: bool | None = None
59
+
60
+
61
+ class ImageSource(BaseModel):
62
+ """Image source data."""
63
+
64
+ type: str = Field(default="base64")
65
+ media_type: str = Field(default="image/jpeg")
66
+ data: str
67
+
68
+
69
+ class ImageContentBlock(ContentBlock):
70
+ """An image content block."""
71
+
72
+ type: str = Field(default="image")
73
+ source: ImageSource
74
+
75
+
76
+ # Union type for all content blocks
77
+ ContentBlockType = (
78
+ TextContentBlock
79
+ | ThinkingContentBlock
80
+ | ToolUseContentBlock
81
+ | ToolResultContentBlock
82
+ | ImageContentBlock
83
+ )
84
+
85
+
86
+ # ============================================================================
87
+ # Message Models
88
+ # ============================================================================
89
+
90
+
91
+ class MessageData(BaseModel):
92
+ """Base message data."""
93
+
94
+ model_config = ConfigDict(
95
+ extra="allow",
96
+ populate_by_name=True,
97
+ )
98
+
99
+
100
+ class AssistantMessageData(MessageData):
101
+ """Assistant message data."""
102
+
103
+ content: list[dict[str, Any]] | str
104
+ model: str = "main"
105
+
106
+
107
+ class UserMessageData(MessageData):
108
+ """User message data."""
109
+
110
+ content: str = ""
111
+
112
+
113
+ class AssistantStreamMessage(BaseModel):
114
+ """An assistant message sent to the SDK."""
115
+
116
+ type: str = Field(default="assistant")
117
+ message: AssistantMessageData
118
+ parent_tool_use_id: str | None = None
119
+
120
+
121
+ class UserStreamMessage(BaseModel):
122
+ """A user message sent to the SDK."""
123
+
124
+ type: str = Field(default="user")
125
+ message: UserMessageData
126
+ uuid: str | None = None
127
+ parent_tool_use_id: str | None = None
128
+ tool_use_result: Any = None
129
+
130
+
131
+ # Union type for stream messages
132
+ StreamMessage = AssistantStreamMessage | UserStreamMessage
133
+
134
+
135
+ # ============================================================================
136
+ # Control Protocol Models
137
+ # ============================================================================
138
+
139
+
140
+ class ControlResponseData(BaseModel):
141
+ """Base class for control response data."""
142
+
143
+ model_config = ConfigDict(
144
+ extra="allow",
145
+ populate_by_name=True,
146
+ )
147
+
148
+
149
+ class ControlResponseSuccess(ControlResponseData):
150
+ """A successful control response."""
151
+
152
+ subtype: str = Field(default="success")
153
+ request_id: str
154
+ response: dict[str, Any] | None = None
155
+
156
+
157
+ class ControlResponseError(ControlResponseData):
158
+ """An error control response."""
159
+
160
+ subtype: str = Field(default="error")
161
+ request_id: str
162
+ error: str
163
+
164
+
165
+ class ControlResponseMessage(BaseModel):
166
+ """A control response message wrapper."""
167
+
168
+ type: str = Field(default="control_response")
169
+ response: ControlResponseSuccess | ControlResponseError
170
+
171
+
172
+ # ============================================================================
173
+ # Result/Usage Models
174
+ # ============================================================================
175
+
176
+
177
+ class UsageInfo(BaseModel):
178
+ """Token usage information."""
179
+
180
+ input_tokens: int = 0
181
+ cache_creation_input_tokens: int = 0
182
+ cache_read_input_tokens: int = 0
183
+ output_tokens: int = 0
184
+
185
+
186
+ class MCPServerInfo(BaseModel):
187
+ """MCP server information."""
188
+
189
+ name: str
190
+
191
+
192
+ class InitializeResponseData(BaseModel):
193
+ """Response data for initialize request."""
194
+
195
+ session_id: str
196
+ system_prompt: str
197
+ tools: list[str]
198
+ mcp_servers: list[MCPServerInfo] = Field(default_factory=list)
199
+ slash_commands: list[Any] = Field(default_factory=list)
200
+ apiKeySource: str = "none"
201
+ claude_code_version: str = "0.1.0"
202
+ output_style: str = "default"
203
+ agents: list[str] = Field(default_factory=list)
204
+ skills: list[Any] = Field(default_factory=list)
205
+ plugins: list[Any] = Field(default_factory=list)
206
+
207
+ model_config = ConfigDict(
208
+ extra="allow",
209
+ populate_by_name=True,
210
+ )
211
+
212
+
213
+ class ResultMessage(BaseModel):
214
+ """A result message sent at the end of a query."""
215
+
216
+ type: str = Field(default="result")
217
+ subtype: str = Field(default="result")
218
+ duration_ms: int
219
+ duration_api_ms: int
220
+ is_error: bool
221
+ num_turns: int
222
+ session_id: str
223
+ total_cost_usd: float | None = None
224
+ usage: UsageInfo | None = None
225
+ result: str | None = None
226
+ structured_output: Any = None
227
+
228
+
229
+ # ============================================================================
230
+ # Permission Response Models
231
+ # ============================================================================
232
+
233
+
234
+ class PermissionResponseAllow(BaseModel):
235
+ """A permission allow response."""
236
+
237
+ decision: str = Field(default="allow")
238
+ updatedInput: dict[str, Any] | None = None
239
+
240
+
241
+ class PermissionResponseDeny(BaseModel):
242
+ """A permission deny response."""
243
+
244
+ decision: str = Field(default="deny")
245
+ message: str = ""
246
+
247
+
248
+ # ============================================================================
249
+ # Helper Functions
250
+ # ============================================================================
251
+
252
+
253
+ def model_to_dict(model: BaseModel) -> dict[str, Any]:
254
+ """Convert a Pydantic model to a JSON-serializable dictionary.
255
+
256
+ This handles exclude_none=True and ensures proper serialization,
257
+ while always including type/subtype fields for protocol messages.
258
+
259
+ Args:
260
+ model: The Pydantic model to convert.
261
+
262
+ Returns:
263
+ A JSON-serializable dictionary.
264
+ """
265
+ return model.model_dump(exclude_none=True, by_alias=True, mode="json")
266
+
267
+
268
+ __all__ = [
269
+ # Content Blocks
270
+ "ContentBlock",
271
+ "TextContentBlock",
272
+ "ThinkingContentBlock",
273
+ "ToolUseContentBlock",
274
+ "ToolResultContentBlock",
275
+ "ImageContentBlock",
276
+ "ImageSource",
277
+ "ContentBlockType",
278
+ # Messages
279
+ "MessageData",
280
+ "AssistantMessageData",
281
+ "UserMessageData",
282
+ "AssistantStreamMessage",
283
+ "UserStreamMessage",
284
+ "StreamMessage",
285
+ # Control Protocol
286
+ "ControlResponseData",
287
+ "ControlResponseSuccess",
288
+ "ControlResponseError",
289
+ "ControlResponseMessage",
290
+ # Result/Usage
291
+ "UsageInfo",
292
+ "MCPServerInfo",
293
+ "InitializeResponseData",
294
+ "ResultMessage",
295
+ # Permissions
296
+ "PermissionResponseAllow",
297
+ "PermissionResponseDeny",
298
+ # Helpers
299
+ "model_to_dict",
300
+ ]