ripperdoc 0.3.0__py3-none-any.whl → 0.3.2__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 (40) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/interrupt_listener.py +233 -0
  13. ripperdoc/cli/ui/message_display.py +7 -0
  14. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  15. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  16. ripperdoc/cli/ui/panels.py +19 -4
  17. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  18. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  19. ripperdoc/cli/ui/provider_options.py +220 -80
  20. ripperdoc/cli/ui/rich_ui.py +91 -83
  21. ripperdoc/cli/ui/tips.py +89 -0
  22. ripperdoc/cli/ui/wizard.py +98 -45
  23. ripperdoc/core/config.py +3 -0
  24. ripperdoc/core/permissions.py +66 -104
  25. ripperdoc/core/providers/anthropic.py +11 -0
  26. ripperdoc/protocol/stdio.py +3 -1
  27. ripperdoc/tools/bash_tool.py +2 -0
  28. ripperdoc/tools/file_edit_tool.py +100 -181
  29. ripperdoc/tools/file_read_tool.py +101 -25
  30. ripperdoc/tools/multi_edit_tool.py +239 -91
  31. ripperdoc/tools/notebook_edit_tool.py +11 -29
  32. ripperdoc/utils/file_editing.py +164 -0
  33. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  34. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  35. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
  36. ripperdoc/cli/ui/interrupt_handler.py +0 -208
  37. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  38. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  39. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  40. {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -3,11 +3,13 @@
3
3
  from __future__ import annotations
4
4
 
5
5
  import asyncio
6
+ import html
6
7
  from collections import defaultdict
7
8
  from dataclasses import dataclass
8
9
  from pathlib import Path
9
10
  from typing import Any, Awaitable, Callable, Optional, Set, TYPE_CHECKING, TYPE_CHECKING as TYPE_CHECKING
10
11
 
12
+ from ripperdoc.cli.ui.choice import prompt_choice
11
13
  from ripperdoc.core.config import config_manager
12
14
  from ripperdoc.core.hooks.manager import hook_manager
13
15
  from ripperdoc.core.tool import Tool
@@ -36,35 +38,37 @@ def _format_input_preview(parsed_input: Any, tool_name: Optional[str] = None) ->
36
38
 
37
39
  For Bash commands, shows full details for security review.
38
40
  For other tools, shows a concise preview.
41
+ Returns HTML-formatted text with color tags.
39
42
  """
40
43
  # For Bash tool, show full command details for security review
41
44
  if tool_name == "Bash" and hasattr(parsed_input, "command"):
42
- lines = [f"Command: {getattr(parsed_input, 'command')}"]
45
+ command = html.escape(getattr(parsed_input, "command"))
46
+ lines = [f"<label>Command:</label> <value>{command}</value>"]
43
47
 
44
48
  # Add other relevant parameters
45
49
  if hasattr(parsed_input, "timeout") and parsed_input.timeout:
46
- lines.append(f"Timeout: {parsed_input.timeout}ms")
50
+ lines.append(f"<label>Timeout:</label> <value>{parsed_input.timeout}ms</value>")
47
51
  if hasattr(parsed_input, "sandbox"):
48
- lines.append(f"Sandbox: {parsed_input.sandbox}")
52
+ lines.append(f"<label>Sandbox:</label> <value>{parsed_input.sandbox}</value>")
49
53
  if hasattr(parsed_input, "run_in_background"):
50
- lines.append(f"Background: {parsed_input.run_in_background}")
54
+ lines.append(f"<label>Background:</label> <value>{parsed_input.run_in_background}</value>")
51
55
  if hasattr(parsed_input, "shell_executable") and parsed_input.shell_executable:
52
- lines.append(f"Shell: {parsed_input.shell_executable}")
56
+ lines.append(f"<label>Shell:</label> <value>{html.escape(parsed_input.shell_executable)}</value>")
53
57
 
54
58
  return "\n ".join(lines)
55
59
 
56
60
  # For other tools with commands, show concise preview
57
61
  if hasattr(parsed_input, "command"):
58
- return f"command='{getattr(parsed_input, 'command')}'"
62
+ return f"<label>command:</label> <value>'{html.escape(getattr(parsed_input, 'command'))}'</value>"
59
63
  if hasattr(parsed_input, "file_path"):
60
- return f"file='{getattr(parsed_input, 'file_path')}'"
64
+ return f"<label>file:</label> <value>'{html.escape(getattr(parsed_input, 'file_path'))}'</value>"
61
65
  if hasattr(parsed_input, "path"):
62
- return f"path='{getattr(parsed_input, 'path')}'"
66
+ return f"<label>path:</label> <value>'{html.escape(getattr(parsed_input, 'path'))}'</value>"
63
67
 
64
68
  preview = str(parsed_input)
65
69
  if len(preview) > 140:
66
- return preview[:137] + "..."
67
- return preview
70
+ preview = preview[:137] + "..."
71
+ return f"<value>{html.escape(preview)}</value>"
68
72
 
69
73
 
70
74
  def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
@@ -92,55 +96,6 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
92
96
  return tool.name
93
97
 
94
98
 
95
- def _render_options_prompt(prompt: str, options: list[tuple[str, str]]) -> str:
96
- """Render a simple numbered prompt (fallback for non-Rich environments)."""
97
- border = "─" * 120
98
- lines = [border, prompt, ""]
99
- for idx, (_, label) in enumerate(options, start=1):
100
- prefix = "❯" if idx == 1 else " "
101
- lines.append(f"{prefix} {idx}. {label}")
102
- numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
103
- shortcut_choices = "/".join(opt[0] for opt in options)
104
- lines.append(f"Choice ({numeric_choices} or {shortcut_choices}): ")
105
- return "\n".join(lines)
106
-
107
-
108
- def _render_options_prompt_rich(
109
- console: "Console",
110
- prompt: str,
111
- options: list[tuple[str, str]]
112
- ) -> None:
113
- """Render permission dialog using Rich Panel for better visual consistency."""
114
- from rich.panel import Panel
115
- from rich.text import Text
116
-
117
- # Build option lines with markup
118
- option_lines = []
119
- for idx, (_, label) in enumerate(options, start=1):
120
- prefix = "[cyan]❯[/cyan]" if idx == 1 else " "
121
- option_lines.append(f"{prefix} {idx}. {label}")
122
-
123
- numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
124
- shortcut_choices = "/".join(opt[0] for opt in options)
125
-
126
- # Build the prompt content as a markup string
127
- markup_content = f"{prompt}\n\n" + "\n".join(option_lines) + "\n"
128
- markup_content += f"Choice ([cyan]{numeric_choices}[/cyan] or [cyan]{shortcut_choices}[/cyan]): "
129
-
130
- # Parse markup to create a Text object
131
- content = Text.from_markup(markup_content)
132
-
133
- # Render the panel
134
- panel = Panel(
135
- content,
136
- title=Text.from_markup("[yellow]Permission Required[/yellow]"),
137
- title_align="left",
138
- border_style="yellow",
139
- padding=(0, 1),
140
- )
141
- console.print(panel)
142
-
143
-
144
99
  def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
145
100
  """Normalize rule suggestions to simple strings."""
146
101
  if not rule_suggestions:
@@ -158,8 +113,8 @@ def make_permission_checker(
158
113
  project_path: Path,
159
114
  yolo_mode: bool,
160
115
  prompt_fn: Optional[Callable[[str], str]] = None,
161
- console: Optional["Console"] = None,
162
- prompt_session: Optional["PromptSession"] = None,
116
+ console: Optional["Console"] = None, # noqa: ARG001 (kept for backward compatibility)
117
+ prompt_session: Optional["PromptSession"] = None, # noqa: ARG001 (kept for backward compatibility)
163
118
  ) -> Callable[[Tool[Any, Any], Any], Awaitable[PermissionResult]]:
164
119
  """Create a permission checking function for the current project.
165
120
 
@@ -167,12 +122,13 @@ def make_permission_checker(
167
122
  project_path: Path to the project directory
168
123
  yolo_mode: If True, all tool calls are allowed without prompting
169
124
  prompt_fn: Optional function to use for prompting (defaults to input())
170
- console: Optional Rich console for rich permission dialogs
171
- prompt_session: Optional PromptSession for better interrupt handling
125
+ console: (Deprecated) No longer used, kept for backward compatibility
126
+ prompt_session: (Deprecated) No longer used, kept for backward compatibility
172
127
 
173
128
  In yolo mode, all tool calls are allowed without prompting.
174
129
  """
175
130
 
131
+ _ = console, prompt_session # Mark as intentionally unused
176
132
  project_path = project_path.resolve()
177
133
  config_manager.get_project_config(project_path)
178
134
 
@@ -180,40 +136,37 @@ def make_permission_checker(
180
136
  session_tool_rules: dict[str, Set[str]] = defaultdict(set)
181
137
 
182
138
  async def _prompt_user(prompt: str, options: list[tuple[str, str]]) -> str:
183
- """Prompt the user with proper interrupt handling."""
184
- # Build the prompt message
185
- if console is not None:
186
- # Use Rich Panel for the dialog
187
- _render_options_prompt_rich(console, prompt, options)
188
- # Build simple prompt for the input line
189
- numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
190
- shortcut_choices = "/".join(opt[0] for opt in options)
191
- input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
192
- else:
193
- # Use plain text rendering
194
- rendered = _render_options_prompt(prompt, options)
195
- input_prompt = rendered
196
-
197
- # Try to use PromptSession if available (better interrupt handling)
198
- if prompt_session is not None:
199
- try:
200
- # PromptSession.prompt() can handle Ctrl+C gracefully
201
- return await prompt_session.prompt_async(input_prompt)
202
- except KeyboardInterrupt:
203
- logger.debug("[permissions] KeyboardInterrupt in prompt_session")
204
- return "n"
205
- except EOFError:
206
- logger.debug("[permissions] EOFError in prompt_session")
207
- return "n"
139
+ """Prompt the user with proper interrupt handling using unified choice component.
208
140
 
209
- # Fallback to simple input() via executor
141
+ Args:
142
+ prompt: The prompt text to display (supports HTML formatting).
143
+ options: List of (value, label) tuples for choices.
144
+ """
210
145
  loop = asyncio.get_running_loop()
211
- responder = prompt_fn or input
212
146
 
213
147
  def _ask() -> str:
214
148
  try:
215
- return responder(input_prompt)
216
- except (KeyboardInterrupt, EOFError):
149
+ # If a custom prompt_fn is provided (e.g., for tests), use it directly
150
+ responder = prompt_fn or None
151
+ if responder is not None:
152
+ # Build a simple text prompt for the prompt_fn
153
+ numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
154
+ shortcut_choices = "/".join(opt[0] for opt in options)
155
+ input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
156
+ return responder(input_prompt)
157
+
158
+ # Use the unified choice component
159
+ return prompt_choice(
160
+ message=prompt,
161
+ options=options,
162
+ allow_esc=True,
163
+ esc_value="n", # ESC means no
164
+ )
165
+ except KeyboardInterrupt:
166
+ logger.debug("[permissions] KeyboardInterrupt in choice")
167
+ return "n"
168
+ except EOFError:
169
+ logger.debug("[permissions] EOFError in choice")
217
170
  return "n"
218
171
 
219
172
  return await loop.run_in_executor(None, _ask)
@@ -264,6 +217,13 @@ def make_permission_checker(
264
217
  | set(local_config.local_deny_rules or [])
265
218
  )
266
219
  }
220
+ ask_rules = {
221
+ "Bash": (
222
+ set(config.bash_ask_rules or [])
223
+ | set(global_config.user_ask_rules or [])
224
+ | set(local_config.local_ask_rules or [])
225
+ )
226
+ }
267
227
  allowed_working_dirs = {
268
228
  str(project_path.resolve()),
269
229
  *[str(Path(p).resolve()) for p in config.working_directories or []],
@@ -281,6 +241,7 @@ def make_permission_checker(
281
241
  {
282
242
  "allowed_rules": allow_rules.get(tool.name, set()),
283
243
  "denied_rules": deny_rules.get(tool.name, set()),
244
+ "ask_rules": ask_rules.get(tool.name, set()),
284
245
  "allowed_working_directories": allowed_working_dirs,
285
246
  },
286
247
  )
@@ -315,7 +276,7 @@ def make_permission_checker(
315
276
 
316
277
  # If tool doesn't normally require permission (e.g., read-only Bash),
317
278
  # enforce deny rules but otherwise skip prompting.
318
- if not needs_permission:
279
+ if not needs_permission and decision.behavior != "ask":
319
280
  if decision.behavior == "deny":
320
281
  return PermissionResult(
321
282
  result=False,
@@ -387,20 +348,21 @@ def make_permission_checker(
387
348
  )
388
349
 
389
350
  input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
390
- prompt_lines = [
391
- f"{tool.name}",
392
- "",
393
- f" {input_preview}",
394
- ]
351
+ # Use inline styles for prompt_toolkit HTML formatting
352
+ # The style names must match keys in the _permission_style() dict
353
+ prompt_html = f"""<title>{html.escape(tool.name)}</title>
354
+
355
+ <description>{input_preview}</description>"""
395
356
  if decision.message:
396
- prompt_lines.append(f" {decision.message}")
397
- prompt_lines.append(" Do you want to proceed?")
398
- prompt = "\n".join(prompt_lines)
357
+ # Use warning style for warning messages
358
+ prompt_html += f"\n <warning>{html.escape(decision.message)}</warning>"
359
+ prompt_html += "\n <question>Do you want to proceed?</question>"
360
+ prompt = prompt_html
399
361
 
400
362
  options = [
401
- ("y", "Yes"),
402
- ("s", "Yes, for this session"),
403
- ("n", "No"),
363
+ ("y", "<yes-option>Yes</yes-option>"),
364
+ ("s", "<yes-option>Yes, for this session</yes-option>"),
365
+ ("n", "<no-option>No</no-option>"),
404
366
  ]
405
367
 
406
368
  answer = (await _prompt_user(prompt, options=options)).strip().lower()
@@ -689,6 +689,17 @@ class AnthropicClient(ProviderClient):
689
689
  if usage:
690
690
  # Update with final usage - output_tokens comes here
691
691
  usage_tokens["output_tokens"] = getattr(usage, "output_tokens", 0)
692
+ # Some APIs (like zhipu) may also include input_tokens here
693
+ input_tokens = getattr(usage, "input_tokens", None)
694
+ if input_tokens is not None:
695
+ usage_tokens["input_tokens"] = input_tokens
696
+ # Also check for cache tokens
697
+ cache_read = getattr(usage, "cache_read_input_tokens", None)
698
+ if cache_read is not None:
699
+ usage_tokens["cache_read_input_tokens"] = cache_read
700
+ cache_creation = getattr(usage, "cache_creation_input_tokens", None)
701
+ if cache_creation is not None:
702
+ usage_tokens["cache_creation_input_tokens"] = cache_creation
692
703
 
693
704
  elif event_type == "message_stop":
694
705
  # Message complete
@@ -23,7 +23,7 @@ from typing import Any, AsyncGenerator, Callable, TypeVar
23
23
  import click
24
24
 
25
25
  from ripperdoc.core.config import get_project_config, get_effective_model_profile
26
- from ripperdoc.core.default_tools import get_default_tools
26
+ from ripperdoc.core.default_tools import filter_tools_by_names, get_default_tools
27
27
  from ripperdoc.core.query import query, QueryContext
28
28
  from ripperdoc.core.query_utils import resolve_model_profile
29
29
  from ripperdoc.core.system_prompt import build_system_prompt
@@ -431,6 +431,8 @@ class StdioProtocolHandler:
431
431
  dynamic_tools = await load_dynamic_mcp_tools_async(self._project_path)
432
432
  if dynamic_tools:
433
433
  tools = merge_tools_with_dynamic(tools, dynamic_tools)
434
+ if allowed_tools is not None:
435
+ tools = filter_tools_by_names(tools, allowed_tools)
434
436
  self._query_context.tools = tools
435
437
 
436
438
  mcp_instructions = format_mcp_instructions(servers)
@@ -316,6 +316,7 @@ build projects, run tests, and interact with the file system."""
316
316
 
317
317
  allow_rules = permission_context.get("allowed_rules") or set()
318
318
  deny_rules = permission_context.get("denied_rules") or set()
319
+ ask_rules = permission_context.get("ask_rules") or set()
319
320
  allowed_dirs = permission_context.get("allowed_working_directories") or {safe_get_cwd()}
320
321
 
321
322
  # Check for sensitive directory access with read-only commands (cd, find).
@@ -340,6 +341,7 @@ build projects, run tests, and interact with the file system."""
340
341
  input_data,
341
342
  allow_rules,
342
343
  deny_rules,
344
+ ask_rules,
343
345
  allowed_dirs,
344
346
  # danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
345
347
  # read_only_detector uses default: _is_command_read_only