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,431 @@
1
+ """Ask user question tool for interactive clarification.
2
+
3
+ This tool allows the AI to ask the user questions during execution,
4
+ enabling clarification of ambiguous instructions, gathering preferences,
5
+ and making decisions on implementation choices.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import asyncio
11
+ from textwrap import dedent
12
+ from typing import AsyncGenerator, Dict, List, Optional
13
+
14
+ from pydantic import BaseModel, Field
15
+
16
+ from ripperdoc.core.tool import (
17
+ Tool,
18
+ ToolOutput,
19
+ ToolResult,
20
+ ToolUseContext,
21
+ ValidationResult,
22
+ )
23
+ from ripperdoc.utils.log import get_logger
24
+
25
+ logger = get_logger()
26
+
27
+ TOOL_NAME = "AskUserQuestion"
28
+ OTHER_VALUE = "__other__"
29
+ HEADER_MAX_CHARS = 12
30
+
31
+ ASK_USER_QUESTION_PROMPT = dedent(
32
+ """\
33
+ Use this tool when you need to ask the user questions during execution. This allows you to:
34
+ 1. Gather user preferences or requirements
35
+ 2. Clarify ambiguous instructions
36
+ 3. Get decisions on implementation choices as you work
37
+ 4. Offer choices to the user about what direction to take.
38
+
39
+ Usage notes:
40
+ - Users will always be able to select "Other" to provide custom text input
41
+ - Use multiSelect: true to allow multiple answers to be selected for a question
42
+ - If you recommend a specific option, make that the first option in the list and add "(Recommended)" at the end of the label
43
+ """
44
+ )
45
+
46
+
47
+ class OptionInput(BaseModel):
48
+ """Single option for a question."""
49
+
50
+ label: str = Field(
51
+ description="The display text for this option that the user will see and select. "
52
+ "Should be concise (1-5 words) and clearly describe the choice."
53
+ )
54
+ description: str = Field(
55
+ description="Explanation of what this option means or what will happen if chosen. "
56
+ "Useful for providing context about trade-offs or implications."
57
+ )
58
+
59
+
60
+ class QuestionInput(BaseModel):
61
+ """Single question to ask the user."""
62
+
63
+ question: str = Field(
64
+ description="The complete question to ask the user. Should be clear, specific, and end "
65
+ 'with a question mark. Example: "Which library should we use for date formatting?" '
66
+ 'If multiSelect is true, phrase it accordingly, e.g. "Which features do you want to enable?"'
67
+ )
68
+ header: str = Field(
69
+ description=f"Very short label displayed as a chip/tag (max {HEADER_MAX_CHARS} chars). "
70
+ 'Examples: "Auth method", "Library", "Approach".'
71
+ )
72
+ options: List[OptionInput] = Field(
73
+ min_length=2,
74
+ max_length=4,
75
+ description="The available choices for this question. Must have 2-4 options. "
76
+ "Each option should be a distinct, mutually exclusive choice (unless multiSelect is enabled). "
77
+ "There should be no 'Other' option, that will be provided automatically.",
78
+ )
79
+ multiSelect: bool = Field(
80
+ description="Set to true to allow the user to select multiple options instead of just one. "
81
+ "Use when choices are not mutually exclusive."
82
+ )
83
+
84
+
85
+ class AskUserQuestionToolInput(BaseModel):
86
+ """Input for the AskUserQuestion tool."""
87
+
88
+ questions: List[QuestionInput] = Field(
89
+ min_length=1,
90
+ max_length=4,
91
+ description="Questions to ask the user (1-4 questions)",
92
+ )
93
+ answers: Optional[Dict[str, str]] = Field(
94
+ default=None,
95
+ description="User answers collected by the permission component",
96
+ )
97
+
98
+
99
+ class AskUserQuestionToolOutput(BaseModel):
100
+ """Output from the AskUserQuestion tool."""
101
+
102
+ questions: List[QuestionInput]
103
+ answers: Dict[str, str]
104
+ cancelled: bool = False
105
+
106
+
107
+ def truncate_header(header: str) -> str:
108
+ """Truncate header to maximum characters."""
109
+ if len(header) <= HEADER_MAX_CHARS:
110
+ return header
111
+ return f"{header[: HEADER_MAX_CHARS - 1]}..."
112
+
113
+
114
+ def format_option_display(option: OptionInput, index: int) -> str:
115
+ """Format a single option for display."""
116
+ desc = f" - {option.description}" if option.description.strip() else ""
117
+ return f" {index}. {option.label}{desc}"
118
+
119
+
120
+ def format_question_prompt(question: QuestionInput, question_num: int, total: int) -> str:
121
+ """Format a question for terminal display."""
122
+ header = truncate_header(question.header)
123
+ lines = [
124
+ "",
125
+ f"[{header}] Question {question_num}/{total}",
126
+ f" {question.question}",
127
+ "",
128
+ ]
129
+
130
+ for idx, opt in enumerate(question.options, start=1):
131
+ lines.append(format_option_display(opt, idx))
132
+
133
+ # Add "Other" option
134
+ lines.append(f" {len(question.options) + 1}. Other (type your own answer)")
135
+
136
+ if question.multiSelect:
137
+ lines.append("")
138
+ lines.append(" Enter numbers separated by commas (e.g., 1,3), or 'o' for other: ")
139
+ else:
140
+ lines.append("")
141
+ lines.append(" Enter choice (1-{}) or 'o' for other: ".format(len(question.options) + 1))
142
+
143
+ return "\n".join(lines)
144
+
145
+
146
+ async def prompt_user_for_answer(
147
+ question: QuestionInput, question_num: int, total: int
148
+ ) -> Optional[str]:
149
+ """Prompt user for an answer to a single question.
150
+
151
+ Returns the answer string, or None if cancelled.
152
+ """
153
+ loop = asyncio.get_running_loop()
154
+
155
+ def _prompt() -> Optional[str]:
156
+ try:
157
+ from prompt_toolkit import prompt as pt_prompt
158
+
159
+ prompt_text = format_question_prompt(question, question_num, total)
160
+ print(prompt_text, end="")
161
+
162
+ while True:
163
+ response = pt_prompt("").strip()
164
+
165
+ if not response:
166
+ print(" Please enter a valid choice.")
167
+ continue
168
+
169
+ if response.lower() in ("q", "quit", "cancel", "exit"):
170
+ return None
171
+
172
+ if response.lower() == "o" or response == str(len(question.options) + 1):
173
+ # Other option selected
174
+ print(" Enter your custom answer: ", end="")
175
+ custom = pt_prompt("")
176
+ if custom.strip():
177
+ return custom.strip()
178
+ print(" Custom answer cannot be empty.")
179
+ continue
180
+
181
+ if question.multiSelect:
182
+ # Parse comma-separated numbers
183
+ try:
184
+ indices = [int(x.strip()) for x in response.split(",")]
185
+ valid_range = range(1, len(question.options) + 2)
186
+ if all(i in valid_range for i in indices):
187
+ selected = []
188
+ for i in indices:
189
+ if i == len(question.options) + 1:
190
+ # Other option
191
+ print(" Enter your custom answer: ", end="")
192
+ custom = pt_prompt("")
193
+ if custom.strip():
194
+ selected.append(custom.strip())
195
+ else:
196
+ selected.append(question.options[i - 1].label)
197
+ if selected:
198
+ return ", ".join(selected)
199
+ print(
200
+ f" Invalid selection. Enter numbers from 1 to {len(question.options) + 1}."
201
+ )
202
+ except ValueError:
203
+ print(" Invalid input. Enter numbers separated by commas.")
204
+ else:
205
+ # Single selection
206
+ try:
207
+ choice = int(response)
208
+ if 1 <= choice <= len(question.options):
209
+ return question.options[choice - 1].label
210
+ elif choice == len(question.options) + 1:
211
+ # Other option
212
+ print(" Enter your custom answer: ", end="")
213
+ custom = pt_prompt("")
214
+ if custom.strip():
215
+ return custom.strip()
216
+ print(" Custom answer cannot be empty.")
217
+ else:
218
+ print(
219
+ f" Invalid choice. Enter a number from 1 to {len(question.options) + 1}."
220
+ )
221
+ except ValueError:
222
+ print(" Invalid input. Enter a number.")
223
+
224
+ except KeyboardInterrupt:
225
+ return None
226
+ except EOFError:
227
+ return None
228
+ except (OSError, RuntimeError, ValueError) as e:
229
+ logger.warning(
230
+ "[ask_user_question_tool] Error during prompt: %s: %s",
231
+ type(e).__name__, e,
232
+ )
233
+ return None
234
+
235
+ return await loop.run_in_executor(None, _prompt)
236
+
237
+
238
+ async def collect_answers(
239
+ questions: List[QuestionInput], initial_answers: Dict[str, str]
240
+ ) -> tuple[Dict[str, str], bool]:
241
+ """Collect answers for all questions.
242
+
243
+ Returns (answers_dict, cancelled_flag).
244
+ """
245
+ answers = dict(initial_answers)
246
+ total = len(questions)
247
+
248
+ for idx, question in enumerate(questions, start=1):
249
+ # Skip if already answered
250
+ if question.question in answers and answers[question.question]:
251
+ continue
252
+
253
+ answer = await prompt_user_for_answer(question, idx, total)
254
+ if answer is None:
255
+ return answers, True # Cancelled
256
+ answers[question.question] = answer
257
+
258
+ return answers, False
259
+
260
+
261
+ class AskUserQuestionTool(Tool[AskUserQuestionToolInput, AskUserQuestionToolOutput]):
262
+ """Tool for asking the user questions interactively."""
263
+
264
+ @property
265
+ def name(self) -> str:
266
+ return TOOL_NAME
267
+
268
+ async def description(self) -> str:
269
+ return (
270
+ "Asks the user multiple choice questions to gather information, "
271
+ "clarify ambiguity, understand preferences, make decisions or offer them choices."
272
+ )
273
+
274
+ @property
275
+ def input_schema(self) -> type[AskUserQuestionToolInput]:
276
+ return AskUserQuestionToolInput
277
+
278
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
279
+ return ASK_USER_QUESTION_PROMPT
280
+
281
+ def user_facing_name(self) -> str:
282
+ return ""
283
+
284
+ def is_read_only(self) -> bool:
285
+ return True
286
+
287
+ def is_concurrency_safe(self) -> bool:
288
+ return True
289
+
290
+ def needs_permissions(
291
+ self,
292
+ input_data: Optional[AskUserQuestionToolInput] = None, # noqa: ARG002
293
+ ) -> bool:
294
+ return False
295
+
296
+ async def validate_input(
297
+ self,
298
+ input_data: AskUserQuestionToolInput,
299
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
300
+ ) -> ValidationResult:
301
+ """Validate that question texts and option labels are unique."""
302
+ seen_questions: set[str] = set()
303
+
304
+ for question in input_data.questions:
305
+ if question.question in seen_questions:
306
+ return ValidationResult(result=False, message="Question texts must be unique")
307
+ seen_questions.add(question.question)
308
+
309
+ option_labels: set[str] = set()
310
+ for option in question.options:
311
+ if option.label in option_labels:
312
+ return ValidationResult(
313
+ result=False,
314
+ message=f'Option labels for "{question.question}" must be unique',
315
+ )
316
+ option_labels.add(option.label)
317
+
318
+ return ValidationResult(result=True)
319
+
320
+ def render_result_for_assistant(self, output: AskUserQuestionToolOutput) -> str:
321
+ """Render the tool output for the AI assistant."""
322
+ if output.cancelled:
323
+ return "User declined to answer your questions."
324
+
325
+ if not output.answers:
326
+ return "User did not provide any answers."
327
+
328
+ serialized = ", ".join(
329
+ f'"{question}"="{answer}"' for question, answer in output.answers.items()
330
+ )
331
+ return (
332
+ f"User has answered your questions: {serialized}. "
333
+ "You can now continue with the user's answers in mind."
334
+ )
335
+
336
+ def render_tool_use_message(
337
+ self,
338
+ input_data: AskUserQuestionToolInput,
339
+ verbose: bool = False, # noqa: ARG002
340
+ ) -> str:
341
+ """Render the tool use message for display."""
342
+ question_count = len(input_data.questions)
343
+ if question_count == 1:
344
+ return f"Asking user: {input_data.questions[0].question}"
345
+ return f"Asking user {question_count} questions"
346
+
347
+ async def call(
348
+ self,
349
+ input_data: AskUserQuestionToolInput,
350
+ context: ToolUseContext,
351
+ ) -> AsyncGenerator[ToolOutput, None]:
352
+ """Execute the tool to ask user questions."""
353
+ questions = input_data.questions
354
+ initial_answers = input_data.answers or {}
355
+
356
+ # Pause UI spinner before user interaction
357
+ if context.pause_ui:
358
+ try:
359
+ context.pause_ui()
360
+ except (RuntimeError, ValueError, OSError):
361
+ logger.debug("[ask_user_question_tool] Failed to pause UI")
362
+
363
+ try:
364
+ # Display introduction
365
+ loop = asyncio.get_running_loop()
366
+
367
+ def _print_intro() -> None:
368
+ print("\n" + "=" * 60)
369
+ print("I need a few answers to proceed:")
370
+ print("=" * 60)
371
+
372
+ await loop.run_in_executor(None, _print_intro)
373
+
374
+ # Collect answers
375
+ answers, cancelled = await collect_answers(questions, initial_answers)
376
+
377
+ if cancelled:
378
+ output = AskUserQuestionToolOutput(
379
+ questions=questions,
380
+ answers=answers,
381
+ cancelled=True,
382
+ )
383
+ yield ToolResult(
384
+ data=output,
385
+ result_for_assistant=self.render_result_for_assistant(output),
386
+ )
387
+ return
388
+
389
+ # Display summary
390
+ def _print_summary() -> None:
391
+ print("\n" + "-" * 40)
392
+ print("Your answers:")
393
+ for q, a in answers.items():
394
+ print(f" - {q}")
395
+ print(f" -> {a}")
396
+ print("-" * 40 + "\n")
397
+
398
+ await loop.run_in_executor(None, _print_summary)
399
+
400
+ output = AskUserQuestionToolOutput(
401
+ questions=questions,
402
+ answers=answers,
403
+ cancelled=False,
404
+ )
405
+ yield ToolResult(
406
+ data=output,
407
+ result_for_assistant=self.render_result_for_assistant(output),
408
+ )
409
+
410
+ except (OSError, RuntimeError, ValueError, KeyError) as exc:
411
+ logger.warning(
412
+ "[ask_user_question_tool] Error collecting answers: %s: %s",
413
+ type(exc).__name__, exc,
414
+ )
415
+ output = AskUserQuestionToolOutput(
416
+ questions=questions,
417
+ answers={},
418
+ cancelled=True,
419
+ )
420
+ yield ToolResult(
421
+ data=output,
422
+ result_for_assistant="Error while collecting user answers: " + str(exc),
423
+ )
424
+
425
+ finally:
426
+ # Resume UI spinner after user interaction
427
+ if context.resume_ui:
428
+ try:
429
+ context.resume_ui()
430
+ except (RuntimeError, ValueError, OSError):
431
+ logger.debug("[ask_user_question_tool] Failed to resume UI")