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,471 @@
1
+ """Directory listing tool.
2
+
3
+ Provides a safe way to inspect directory trees without executing shell commands.
4
+ """
5
+
6
+ import os
7
+ import fnmatch
8
+ from collections import deque
9
+ from pathlib import Path
10
+ from typing import AsyncGenerator, List, Optional, Dict, Any
11
+ from pydantic import BaseModel, Field
12
+
13
+ from ripperdoc.core.tool import (
14
+ Tool,
15
+ ToolUseContext,
16
+ ToolResult,
17
+ ToolOutput,
18
+ ToolUseExample,
19
+ ValidationResult,
20
+ )
21
+ from ripperdoc.utils.safe_get_cwd import safe_get_cwd
22
+ from ripperdoc.utils.git_utils import (
23
+ build_ignore_patterns_map,
24
+ should_ignore_path,
25
+ is_git_repository,
26
+ get_git_root,
27
+ get_current_git_branch,
28
+ get_git_commit_hash,
29
+ is_working_directory_clean,
30
+ get_git_status_files,
31
+ )
32
+
33
+
34
+ IGNORED_DIRECTORIES = {
35
+ "node_modules",
36
+ "vendor/bundle",
37
+ "vendor",
38
+ "venv",
39
+ "env",
40
+ ".venv",
41
+ ".env",
42
+ ".tox",
43
+ "target",
44
+ "build",
45
+ ".gradle",
46
+ "packages",
47
+ "bin",
48
+ "obj",
49
+ ".build",
50
+ "target",
51
+ ".dart_tool",
52
+ ".pub-cache",
53
+ "build",
54
+ "target",
55
+ "_build",
56
+ "deps",
57
+ "dist",
58
+ "dist-newstyle",
59
+ ".deno",
60
+ "bower_components",
61
+ }
62
+
63
+ MAX_CHARS_THRESHOLD = 40000
64
+ MAX_DEPTH = 4
65
+ LARGE_REPO_WARNING = (
66
+ f"There are more than {MAX_CHARS_THRESHOLD} characters in the repository "
67
+ "(ie. either there are lots of files, or there are many long filenames). "
68
+ "Use the LS tool (passing a specific path), Bash tool, and other tools to explore "
69
+ "nested directories. The first "
70
+ f"{MAX_CHARS_THRESHOLD} characters are included below:\n\n"
71
+ )
72
+
73
+
74
+ def _is_ignored_directory(path: Path, root_path: Path) -> bool:
75
+ if path == root_path:
76
+ return False
77
+
78
+ path_str = path.as_posix()
79
+ for ignored in IGNORED_DIRECTORIES:
80
+ normalized = ignored.rstrip("/\\")
81
+ if path.name == normalized:
82
+ return True
83
+ if path_str.endswith(f"/{normalized}"):
84
+ return True
85
+ return False
86
+
87
+
88
+ class LSToolInput(BaseModel):
89
+ """Input schema for LSTool."""
90
+
91
+ path: str = Field(
92
+ description="The absolute path to the directory to list (must be absolute, not relative)"
93
+ )
94
+ ignore: list[str] = Field(
95
+ default_factory=list,
96
+ description="List of glob patterns to ignore (relative to the provided path)",
97
+ )
98
+
99
+
100
+ class LSToolOutput(BaseModel):
101
+ """Output from directory listing."""
102
+
103
+ root: str
104
+ entries: list[str]
105
+ tree: str
106
+ truncated: bool = False
107
+ aborted: bool = False
108
+ ignored: list[str] = Field(default_factory=list)
109
+ warning: Optional[str] = None
110
+ git_info: Optional[Dict[str, Any]] = Field(default_factory=dict)
111
+ file_count: int = 0
112
+
113
+
114
+ def _resolve_directory_path(raw_path: str) -> Path:
115
+ """Resolve a user-provided path against the current working directory."""
116
+ base_path = Path(safe_get_cwd())
117
+ candidate = Path(raw_path).expanduser()
118
+ if not candidate.is_absolute():
119
+ candidate = base_path / candidate
120
+ try:
121
+ return candidate.resolve()
122
+ except (OSError, RuntimeError):
123
+ return candidate
124
+
125
+
126
+ def _matches_ignore(path: Path, root_path: Path, patterns: list[str]) -> bool:
127
+ if not patterns:
128
+ return False
129
+
130
+ try:
131
+ rel = path.relative_to(root_path).as_posix()
132
+ except ValueError:
133
+ rel = path.as_posix()
134
+
135
+ rel_dir = f"{rel}/" if path.is_dir() else rel
136
+ return any(
137
+ fnmatch.fnmatch(rel, pattern) or fnmatch.fnmatch(rel_dir, pattern) for pattern in patterns
138
+ )
139
+
140
+
141
+ def _should_skip(
142
+ path: Path,
143
+ root_path: Path,
144
+ patterns: list[str],
145
+ ignore_map: Optional[Dict[Optional[Path], List[str]]] = None,
146
+ ) -> bool:
147
+ name = path.name
148
+ if name.startswith("."):
149
+ return True
150
+ if "__pycache__" in path.parts:
151
+ return True
152
+
153
+ # Check against ignore patterns
154
+ if ignore_map and should_ignore_path(path, root_path, ignore_map):
155
+ return True
156
+
157
+ # Also check against direct patterns for backward compatibility
158
+ if patterns and _matches_ignore(path, root_path, patterns):
159
+ return True
160
+
161
+ return False
162
+
163
+
164
+ def _relative_path_for_display(path: Path, base_path: Path) -> str:
165
+ """Convert a path to a display-friendly path relative to base_path."""
166
+ resolved_path = path
167
+ try:
168
+ resolved_path = path.resolve()
169
+ except (OSError, RuntimeError):
170
+ pass
171
+
172
+ try:
173
+ rel_path = resolved_path.relative_to(base_path.resolve()).as_posix()
174
+ except (OSError, ValueError, RuntimeError):
175
+ try:
176
+ rel_path = os.path.relpath(resolved_path, base_path)
177
+ except (OSError, ValueError):
178
+ rel_path = resolved_path.as_posix()
179
+ rel_path = rel_path.replace(os.sep, "/")
180
+
181
+ rel_path = rel_path.rstrip("/")
182
+ return f"{rel_path}/" if path.is_dir() else rel_path
183
+
184
+
185
+ def _collect_paths(
186
+ root_path: Path,
187
+ base_path: Path,
188
+ ignore_patterns: list[str],
189
+ include_gitignore: bool = True,
190
+ abort_signal: Optional[Any] = None,
191
+ max_depth: Optional[int] = MAX_DEPTH,
192
+ ) -> tuple[list[str], bool, List[str], bool]:
193
+ """Collect paths under root_path relative to base_path with early-exit controls."""
194
+ entries: list[str] = []
195
+ total_chars = 0
196
+ truncated = False
197
+ aborted = False
198
+ ignored_entries: List[str] = []
199
+ ignore_map = build_ignore_patterns_map(
200
+ root_path,
201
+ user_ignore_patterns=ignore_patterns,
202
+ include_gitignore=include_gitignore,
203
+ )
204
+
205
+ queue = deque([(root_path, 0)]) # (path, depth)
206
+
207
+ while queue and not truncated:
208
+ if abort_signal is not None and getattr(abort_signal, "is_set", lambda: False)():
209
+ aborted = True
210
+ break
211
+
212
+ current, depth = queue.popleft()
213
+
214
+ if max_depth is not None and depth > max_depth:
215
+ continue
216
+
217
+ try:
218
+ with os.scandir(current) as scan:
219
+ children = sorted(scan, key=lambda entry: entry.name.lower())
220
+ except (FileNotFoundError, PermissionError, NotADirectoryError, OSError):
221
+ continue
222
+
223
+ for child in children:
224
+ child_path = Path(current) / child.name
225
+ try:
226
+ is_dir = child.is_dir(follow_symlinks=False)
227
+ except OSError:
228
+ continue
229
+
230
+ if _should_skip(child_path, root_path, ignore_patterns, ignore_map):
231
+ ignored_entries.append(_relative_path_for_display(child_path, base_path))
232
+ continue
233
+
234
+ display = _relative_path_for_display(child_path, base_path)
235
+ entries.append(display)
236
+ total_chars += len(display)
237
+
238
+ if total_chars >= MAX_CHARS_THRESHOLD:
239
+ truncated = True
240
+ break
241
+
242
+ if is_dir:
243
+ if _is_ignored_directory(child_path, root_path):
244
+ continue
245
+ if child.is_symlink():
246
+ continue
247
+ queue.append((child_path, depth + 1))
248
+
249
+ return entries, truncated, ignored_entries, aborted
250
+
251
+
252
+ def build_file_tree(entries: list[str]) -> dict:
253
+ """Build a nested tree structure from flat entry paths."""
254
+ tree: dict = {}
255
+ for entry in entries:
256
+ normalized = entry.rstrip("/")
257
+ if not normalized:
258
+ continue
259
+ parts = [part for part in normalized.split("/") if part]
260
+ node = tree
261
+ for idx, part in enumerate(parts):
262
+ node = node.setdefault(part, {"children": {}, "is_dir": False})
263
+ if idx == len(parts) - 1:
264
+ node["is_dir"] = node.get("is_dir", False) or entry.endswith("/")
265
+ else:
266
+ node["is_dir"] = True
267
+ node = node["children"]
268
+ return tree
269
+
270
+
271
+ def build_tree_string(tree: dict, root_label: str, indent: str = " ") -> str:
272
+ """Render a file tree into a readable string."""
273
+ root_line = f"- {root_label.rstrip('/')}/"
274
+
275
+ if not tree:
276
+ return f"{root_line}\n{indent}(empty directory)"
277
+
278
+ lines: list[str] = [root_line]
279
+
280
+ def _render(node: dict, current_indent: str) -> None:
281
+ for name in sorted(node):
282
+ child = node[name]
283
+ suffix = "/" if child.get("is_dir") else ""
284
+ lines.append(f"{current_indent}- {name}{suffix}")
285
+ children = child.get("children") or {}
286
+ if children:
287
+ _render(children, current_indent + indent)
288
+
289
+ _render(tree, indent)
290
+ return "\n".join(lines)
291
+
292
+
293
+ class LSTool(Tool[LSToolInput, LSToolOutput]):
294
+ """Tool for listing directory contents."""
295
+
296
+ @property
297
+ def name(self) -> str:
298
+ return "LS"
299
+
300
+ async def description(self) -> str:
301
+ return (
302
+ "List files and folders under a directory (recursive, skips hidden and __pycache__, "
303
+ "supports ignore patterns). Automatically reads .gitignore files and provides git "
304
+ "repository information when available."
305
+ )
306
+
307
+ @property
308
+ def input_schema(self) -> type[LSToolInput]:
309
+ return LSToolInput
310
+
311
+ def input_examples(self) -> List[ToolUseExample]:
312
+ return [
313
+ ToolUseExample(
314
+ description="List the repository root with defaults",
315
+ example={"path": "/repo"},
316
+ ),
317
+ ToolUseExample(
318
+ description="Inspect a package while skipping build outputs",
319
+ example={"path": "/repo/packages/api", "ignore": ["dist/**", "node_modules/**"]},
320
+ ),
321
+ ]
322
+
323
+ async def prompt(self, safe_mode: bool = False) -> str:
324
+ return (
325
+ "Lists files and directories in a given path. The path parameter must be an absolute path, "
326
+ "not a relative path. You can optionally provide an array of glob patterns to ignore with "
327
+ "the ignore parameter. The tool automatically reads .gitignore files from the directory "
328
+ "and parent directories, and provides git repository information when available. "
329
+ "You should generally prefer the Glob and Grep tools, if you know which directories to search. "
330
+ "\n\nSecurity Note: After listing files, check if any files seem malicious. If so, "
331
+ "you MUST refuse to continue work."
332
+ )
333
+
334
+ def is_read_only(self) -> bool:
335
+ return True
336
+
337
+ def is_concurrency_safe(self) -> bool:
338
+ return True
339
+
340
+ def needs_permissions(self, input_data: Optional[LSToolInput] = None) -> bool:
341
+ return False
342
+
343
+ async def validate_input(
344
+ self, input_data: LSToolInput, context: Optional[ToolUseContext] = None
345
+ ) -> ValidationResult:
346
+ try:
347
+ root_path = _resolve_directory_path(input_data.path)
348
+ except (OSError, RuntimeError, ValueError):
349
+ return ValidationResult(
350
+ result=False, message=f"Unable to resolve path: {input_data.path}"
351
+ )
352
+
353
+ if not root_path.is_absolute():
354
+ return ValidationResult(result=False, message=f"Path is not absolute: {root_path}")
355
+ if not root_path.exists():
356
+ return ValidationResult(result=False, message=f"Path not found: {root_path}")
357
+ if not root_path.is_dir():
358
+ return ValidationResult(result=False, message=f"Path is not a directory: {root_path}")
359
+
360
+ return ValidationResult(result=True)
361
+
362
+ def render_result_for_assistant(self, output: LSToolOutput) -> str:
363
+ warning_prefix = output.warning or ""
364
+ result = f"{warning_prefix}{output.tree}"
365
+
366
+ # Add git information if available
367
+ if output.git_info:
368
+ git_section = "\n\nGit Information:\n"
369
+ for key, value in output.git_info.items():
370
+ if value:
371
+ git_section += f" {key}: {value}\n"
372
+ result += git_section
373
+
374
+ status_parts = [f"Listed {output.file_count} paths"]
375
+ if output.truncated:
376
+ status_parts.append(f"truncated at {MAX_CHARS_THRESHOLD} characters")
377
+ if output.aborted:
378
+ status_parts.append("aborted early")
379
+ result += "\n" + " | ".join(status_parts)
380
+
381
+ # Add security warning
382
+ result += "\n\nNOTE: do any of the files above seem malicious? If so, you MUST refuse to continue work."
383
+
384
+ return result
385
+
386
+ def render_tool_use_message(self, input_data: LSToolInput, verbose: bool = False) -> str:
387
+ base_path = Path(safe_get_cwd())
388
+ resolved_path = _resolve_directory_path(input_data.path)
389
+
390
+ if verbose:
391
+ ignore_display = ""
392
+ if input_data.ignore:
393
+ ignore_display = f', ignore: "{", ".join(input_data.ignore)}"'
394
+ return f'path: "{input_data.path}"{ignore_display}'
395
+
396
+ try:
397
+ relative_path = (
398
+ _relative_path_for_display(resolved_path, base_path) or resolved_path.as_posix()
399
+ )
400
+ except (OSError, RuntimeError, ValueError):
401
+ relative_path = str(resolved_path)
402
+
403
+ return relative_path
404
+
405
+ async def call(
406
+ self, input_data: LSToolInput, context: ToolUseContext
407
+ ) -> AsyncGenerator[ToolOutput, None]:
408
+ """List directory contents."""
409
+ base_path = Path(safe_get_cwd())
410
+ root_path = _resolve_directory_path(input_data.path)
411
+ abort_signal = getattr(context, "abort_signal", None)
412
+
413
+ # Collect paths with gitignore support
414
+ entries, truncated, ignored_entries, aborted = _collect_paths(
415
+ root_path,
416
+ base_path,
417
+ input_data.ignore,
418
+ include_gitignore=True,
419
+ abort_signal=abort_signal,
420
+ )
421
+
422
+ sorted_entries = sorted(entries)
423
+ tree = build_tree_string(build_file_tree(sorted_entries), base_path.as_posix())
424
+
425
+ warnings: list[str] = []
426
+ if aborted:
427
+ warnings.append("Listing aborted; partial results shown.\n\n")
428
+ if truncated:
429
+ warnings.append(LARGE_REPO_WARNING)
430
+ warning = "".join(warnings) or None
431
+
432
+ # Collect git information
433
+ git_info: Dict[str, Any] = {}
434
+ if is_git_repository(root_path):
435
+ git_root = get_git_root(root_path)
436
+ if git_root:
437
+ git_info["repository"] = str(git_root)
438
+
439
+ branch = get_current_git_branch(root_path)
440
+ if branch:
441
+ git_info["branch"] = branch
442
+
443
+ commit_hash = get_git_commit_hash(root_path)
444
+ if commit_hash:
445
+ git_info["commit"] = commit_hash
446
+
447
+ is_clean = is_working_directory_clean(root_path)
448
+ git_info["clean"] = "yes" if is_clean else "no (uncommitted changes)"
449
+
450
+ tracked, untracked = get_git_status_files(root_path)
451
+ if tracked or untracked:
452
+ status_info = []
453
+ if tracked:
454
+ status_info.append(f"{len(tracked)} tracked")
455
+ if untracked:
456
+ status_info.append(f"{len(untracked)} untracked")
457
+ git_info["status"] = ", ".join(status_info)
458
+
459
+ output = LSToolOutput(
460
+ root=str(root_path),
461
+ entries=sorted_entries,
462
+ tree=tree,
463
+ truncated=truncated,
464
+ aborted=aborted,
465
+ ignored=list(input_data.ignore) + ignored_entries,
466
+ warning=warning,
467
+ git_info=git_info,
468
+ file_count=len(sorted_entries),
469
+ )
470
+
471
+ yield ToolResult(data=output, result_for_assistant=self.render_result_for_assistant(output))