ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,9 @@
1
+ """Lightweight Python SDK for using Ripperdoc headlessly."""
2
+
3
+ from ripperdoc.sdk.client import (
4
+ RipperdocClient,
5
+ RipperdocOptions,
6
+ query,
7
+ )
8
+
9
+ __all__ = ["RipperdocClient", "RipperdocOptions", "query"]
@@ -0,0 +1,333 @@
1
+ """Headless Python SDK for Ripperdoc.
2
+
3
+ `query` helper for simple calls and a `RipperdocClient` for long-lived
4
+ sessions that keep conversation history.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import asyncio
10
+ import os
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+ from typing import (
14
+ Any,
15
+ AsyncIterator,
16
+ Awaitable,
17
+ Callable,
18
+ Dict,
19
+ List,
20
+ Optional,
21
+ Sequence,
22
+ Tuple,
23
+ Union,
24
+ )
25
+
26
+ from ripperdoc.core.default_tools import get_default_tools
27
+ from ripperdoc.core.query import QueryContext, query as _core_query
28
+ from ripperdoc.core.permissions import PermissionResult
29
+ from ripperdoc.core.system_prompt import build_system_prompt
30
+ from ripperdoc.core.skills import build_skill_summary, load_all_skills
31
+ from ripperdoc.core.tool import Tool
32
+ from ripperdoc.tools.task_tool import TaskTool
33
+ from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
34
+ from ripperdoc.utils.memory import build_memory_instructions
35
+ from ripperdoc.utils.messages import (
36
+ AssistantMessage,
37
+ ProgressMessage,
38
+ UserMessage,
39
+ create_user_message,
40
+ )
41
+ from ripperdoc.utils.mcp import (
42
+ format_mcp_instructions,
43
+ load_mcp_servers_async,
44
+ shutdown_mcp_runtime,
45
+ )
46
+ from ripperdoc.utils.log import get_logger
47
+
48
+ MessageType = Union[UserMessage, AssistantMessage, ProgressMessage]
49
+ PermissionChecker = Callable[
50
+ [Tool[Any, Any], Any],
51
+ Union[
52
+ PermissionResult,
53
+ Dict[str, Any],
54
+ Tuple[bool, Optional[str]],
55
+ bool,
56
+ Awaitable[Union[PermissionResult, Dict[str, Any], Tuple[bool, Optional[str]], bool]],
57
+ ],
58
+ ]
59
+ QueryRunner = Callable[
60
+ [
61
+ List[MessageType],
62
+ str,
63
+ Dict[str, str],
64
+ QueryContext,
65
+ Optional[PermissionChecker],
66
+ ],
67
+ AsyncIterator[MessageType],
68
+ ]
69
+
70
+ _END_OF_STREAM = object()
71
+
72
+ logger = get_logger()
73
+
74
+
75
+ def _coerce_to_path(path: Union[str, Path]) -> Path:
76
+ return path if isinstance(path, Path) else Path(path)
77
+
78
+
79
+ @dataclass
80
+ class RipperdocOptions:
81
+ """Configuration for SDK usage."""
82
+
83
+ tools: Optional[Sequence[Tool[Any, Any]]] = None
84
+ allowed_tools: Optional[Sequence[str]] = None
85
+ disallowed_tools: Optional[Sequence[str]] = None
86
+ safe_mode: bool = False
87
+ verbose: bool = False
88
+ model: str = "main"
89
+ max_thinking_tokens: int = 0
90
+ context: Dict[str, str] = field(default_factory=dict)
91
+ system_prompt: Optional[str] = None
92
+ additional_instructions: Optional[Union[str, Sequence[str]]] = None
93
+ permission_checker: Optional[PermissionChecker] = None
94
+ cwd: Optional[Union[str, Path]] = None
95
+
96
+ def build_tools(self) -> List[Tool[Any, Any]]:
97
+ """Create the tool set with allow/deny filters applied."""
98
+ base_tools = list(self.tools) if self.tools is not None else get_default_tools()
99
+ allowed = set(self.allowed_tools) if self.allowed_tools is not None else None
100
+ disallowed = set(self.disallowed_tools or [])
101
+
102
+ filtered: List[Tool[Any, Any]] = []
103
+ for tool in base_tools:
104
+ name = getattr(tool, "name", tool.__class__.__name__)
105
+ if allowed is not None and name not in allowed:
106
+ continue
107
+ if name in disallowed:
108
+ continue
109
+ filtered.append(tool)
110
+
111
+ if allowed is not None and not filtered:
112
+ raise ValueError("No tools remain after applying allowed_tools/disallowed_tools.")
113
+
114
+ # The default Task tool captures the original base tools. If filters are
115
+ # applied, recreate it so the subagent only sees the filtered set.
116
+ if (self.allowed_tools or self.disallowed_tools) and self.tools is None:
117
+ has_task = any(getattr(tool, "name", None) == "Task" for tool in filtered)
118
+ if has_task:
119
+ filtered_base = [tool for tool in filtered if getattr(tool, "name", None) != "Task"]
120
+
121
+ def _filtered_base_provider() -> List[Tool[Any, Any]]:
122
+ return filtered_base
123
+
124
+ filtered = [
125
+ (
126
+ TaskTool(_filtered_base_provider)
127
+ if getattr(tool, "name", None) == "Task"
128
+ else tool
129
+ )
130
+ for tool in filtered
131
+ ]
132
+
133
+ return filtered
134
+
135
+ def extra_instructions(self) -> List[str]:
136
+ """Normalize additional instructions to a list."""
137
+ if self.additional_instructions is None:
138
+ return []
139
+ if isinstance(self.additional_instructions, str):
140
+ return [self.additional_instructions]
141
+ return [text for text in self.additional_instructions if text]
142
+
143
+
144
+ class RipperdocClient:
145
+ """Persistent Ripperdoc session with conversation history."""
146
+
147
+ def __init__(
148
+ self,
149
+ options: Optional[RipperdocOptions] = None,
150
+ query_runner: Optional[QueryRunner] = None,
151
+ ) -> None:
152
+ self.options = options or RipperdocOptions()
153
+ self._tools = self.options.build_tools()
154
+ self._query_runner = query_runner or _core_query
155
+
156
+ self._history: List[MessageType] = []
157
+ self._queue: asyncio.Queue = asyncio.Queue()
158
+ self._current_task: Optional[asyncio.Task] = None
159
+ self._current_context: Optional[QueryContext] = None
160
+ self._connected = False
161
+ self._previous_cwd: Optional[Path] = None
162
+
163
+ @property
164
+ def tools(self) -> List[Tool[Any, Any]]:
165
+ return self._tools
166
+
167
+ @property
168
+ def history(self) -> List[MessageType]:
169
+ return list(self._history)
170
+
171
+ async def __aenter__(self) -> "RipperdocClient":
172
+ await self.connect()
173
+ return self
174
+
175
+ async def __aexit__(self, exc_type: Any, exc: Any, tb: Any) -> None: # type: ignore[override]
176
+ await self.disconnect()
177
+
178
+ async def connect(self, prompt: Optional[str] = None) -> None:
179
+ """Prepare the session and optionally send an initial prompt."""
180
+ if not self._connected:
181
+ if self.options.cwd is not None:
182
+ self._previous_cwd = Path.cwd()
183
+ os.chdir(_coerce_to_path(self.options.cwd))
184
+ self._connected = True
185
+
186
+ if prompt:
187
+ await self.query(prompt)
188
+
189
+ async def disconnect(self) -> None:
190
+ """Tear down the session and restore the working directory."""
191
+ if self._current_context:
192
+ self._current_context.abort_controller.set()
193
+
194
+ if self._current_task and not self._current_task.done():
195
+ self._current_task.cancel()
196
+ try:
197
+ await self._current_task
198
+ except asyncio.CancelledError:
199
+ pass
200
+
201
+ if self._previous_cwd:
202
+ os.chdir(self._previous_cwd)
203
+ self._previous_cwd = None
204
+
205
+ self._connected = False
206
+ await shutdown_mcp_runtime()
207
+
208
+ async def query(self, prompt: str) -> None:
209
+ """Send a prompt and start streaming the response."""
210
+ if self._current_task and not self._current_task.done():
211
+ raise RuntimeError(
212
+ "A query is already in progress; wait for it to finish or interrupt it."
213
+ )
214
+
215
+ if not self._connected:
216
+ await self.connect()
217
+
218
+ self._queue = asyncio.Queue()
219
+
220
+ user_message = create_user_message(prompt)
221
+ history = list(self._history) + [user_message]
222
+ self._history.append(user_message)
223
+
224
+ system_prompt = await self._build_system_prompt(prompt)
225
+ context = dict(self.options.context)
226
+
227
+ query_context = QueryContext(
228
+ tools=self._tools,
229
+ max_thinking_tokens=self.options.max_thinking_tokens,
230
+ safe_mode=self.options.safe_mode,
231
+ model=self.options.model,
232
+ verbose=self.options.verbose,
233
+ )
234
+ self._current_context = query_context
235
+
236
+ async def _runner() -> None:
237
+ try:
238
+ async for message in self._query_runner(
239
+ history,
240
+ system_prompt,
241
+ context,
242
+ query_context,
243
+ self.options.permission_checker,
244
+ ):
245
+ if getattr(message, "type", None) in ("user", "assistant"):
246
+ self._history.append(message) # type: ignore[arg-type]
247
+ await self._queue.put(message)
248
+ finally:
249
+ await self._queue.put(_END_OF_STREAM)
250
+
251
+ self._current_task = asyncio.create_task(_runner())
252
+
253
+ async def receive_messages(self) -> AsyncIterator[MessageType]:
254
+ """Yield messages for the active query."""
255
+ if self._current_task is None:
256
+ raise RuntimeError("No active query to receive messages from.")
257
+
258
+ while True:
259
+ message = await self._queue.get()
260
+ if message is _END_OF_STREAM:
261
+ break
262
+ yield message # type: ignore[misc]
263
+
264
+ async def receive_response(self) -> AsyncIterator[MessageType]:
265
+ """Alias for receive_messages."""
266
+ async for message in self.receive_messages():
267
+ yield message
268
+
269
+ async def interrupt(self) -> None:
270
+ """Request cancellation of the active query."""
271
+ if self._current_context:
272
+ self._current_context.abort_controller.set()
273
+
274
+ if self._current_task and not self._current_task.done():
275
+ self._current_task.cancel()
276
+ try:
277
+ await self._current_task
278
+ except asyncio.CancelledError:
279
+ pass
280
+
281
+ await self._queue.put(_END_OF_STREAM)
282
+
283
+ async def _build_system_prompt(self, user_prompt: str) -> str:
284
+ if self.options.system_prompt:
285
+ return self.options.system_prompt
286
+
287
+ instructions: List[str] = []
288
+ project_path = _coerce_to_path(self.options.cwd or Path.cwd())
289
+ skill_result = load_all_skills(project_path)
290
+ for err in skill_result.errors:
291
+ logger.warning(
292
+ "[skills] Failed to load skill",
293
+ extra={"path": str(err.path), "reason": err.reason},
294
+ )
295
+ skill_instructions = build_skill_summary(skill_result.skills)
296
+ if skill_instructions:
297
+ instructions.append(skill_instructions)
298
+ instructions.extend(self.options.extra_instructions())
299
+ memory = build_memory_instructions()
300
+ if memory:
301
+ instructions.append(memory)
302
+
303
+ dynamic_tools = await load_dynamic_mcp_tools_async(project_path)
304
+ if dynamic_tools:
305
+ self._tools = merge_tools_with_dynamic(self._tools, dynamic_tools)
306
+
307
+ servers = await load_mcp_servers_async(project_path)
308
+ mcp_instructions = format_mcp_instructions(servers)
309
+
310
+ return build_system_prompt(
311
+ self._tools,
312
+ user_prompt,
313
+ dict(self.options.context),
314
+ instructions or None,
315
+ mcp_instructions=mcp_instructions,
316
+ )
317
+
318
+
319
+ async def query(
320
+ prompt: str,
321
+ options: Optional[RipperdocOptions] = None,
322
+ query_runner: Optional[QueryRunner] = None,
323
+ ) -> AsyncIterator[MessageType]:
324
+ """One-shot helper: run a prompt in a fresh session."""
325
+ client = RipperdocClient(options=options, query_runner=query_runner)
326
+ await client.connect()
327
+ await client.query(prompt)
328
+
329
+ try:
330
+ async for message in client.receive_messages():
331
+ yield message
332
+ finally:
333
+ await client.disconnect()
@@ -0,0 +1 @@
1
+ """Tool implementations for Ripperdoc."""