ripperdoc 0.3.1__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 (37) 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/models_tui/__init__.py +5 -0
  13. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  14. ripperdoc/cli/ui/panels.py +19 -4
  15. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  16. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  17. ripperdoc/cli/ui/provider_options.py +220 -80
  18. ripperdoc/cli/ui/rich_ui.py +9 -11
  19. ripperdoc/cli/ui/tips.py +89 -0
  20. ripperdoc/cli/ui/wizard.py +98 -45
  21. ripperdoc/core/config.py +3 -0
  22. ripperdoc/core/permissions.py +25 -70
  23. ripperdoc/core/providers/anthropic.py +11 -0
  24. ripperdoc/protocol/stdio.py +3 -1
  25. ripperdoc/tools/bash_tool.py +2 -0
  26. ripperdoc/tools/file_edit_tool.py +100 -181
  27. ripperdoc/tools/file_read_tool.py +101 -25
  28. ripperdoc/tools/multi_edit_tool.py +239 -91
  29. ripperdoc/tools/notebook_edit_tool.py +11 -29
  30. ripperdoc/utils/file_editing.py +164 -0
  31. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  32. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  33. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
  34. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  35. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -8,6 +8,7 @@ from typing import List, Optional, Tuple
8
8
  import click
9
9
  from rich.console import Console
10
10
 
11
+ from ripperdoc.cli.ui.choice import ChoiceOption, onboarding_style, prompt_choice
11
12
  from ripperdoc.cli.ui.provider_options import (
12
13
  KNOWN_PROVIDERS,
13
14
  ProviderOption,
@@ -66,38 +67,64 @@ def check_onboarding() -> bool:
66
67
 
67
68
  def run_onboarding_wizard(config: GlobalConfig) -> bool:
68
69
  """Run interactive onboarding wizard."""
69
- provider_keys = KNOWN_PROVIDERS.keys() + ["custom"]
70
+ provider_keys = list(KNOWN_PROVIDERS.keys()) + ["custom"]
70
71
  default_choice_key = KNOWN_PROVIDERS.default_choice.key
71
72
 
72
- # Display provider options vertically
73
- console.print("[bold]Available providers:[/bold]")
74
- for i, provider_key in enumerate(provider_keys, 1):
75
- marker = "[cyan]→[/cyan]" if provider_key == default_choice_key else " "
76
- console.print(f" {marker} {i}. {provider_key}")
77
- console.print("")
78
-
79
- # Prompt for provider choice with validation
80
- provider_choice: Optional[str] = None
81
- while provider_choice is None:
82
- raw_choice = click.prompt(
83
- "Choose your model provider",
84
- default=default_choice_key,
73
+ # Build provider choice options with rich styling
74
+ provider_options = [
75
+ ChoiceOption(
76
+ key,
77
+ key, # Plain text, not colored
78
+ is_default=(key == default_choice_key),
85
79
  )
86
- provider_choice = resolve_provider_choice(raw_choice, provider_keys)
87
- if provider_choice is None:
88
- console.print(
89
- f"[red]Invalid choice. Please enter a provider name or number (1-{len(provider_keys)}).[/red]"
90
- )
80
+ for key in provider_keys
81
+ ]
82
+
83
+ # Prompt for provider choice using unified choice component
84
+ provider_choice = prompt_choice(
85
+ message="Choose your model provider",
86
+ options=provider_options,
87
+ title="Available providers",
88
+ allow_esc=True,
89
+ esc_value=default_choice_key,
90
+ style=onboarding_style(),
91
+ )
92
+
93
+ # Validate the choice (in case user typed an invalid value)
94
+ validated_choice = resolve_provider_choice(provider_choice, provider_keys)
95
+ if validated_choice is None:
96
+ console.print(
97
+ f"[red]Invalid choice. Please enter a provider name or number (1-{len(provider_keys)}).[/red]"
98
+ )
99
+ return run_onboarding_wizard(config)
100
+ provider_choice = validated_choice
91
101
 
92
102
  api_base_override: Optional[str] = None
93
103
  if provider_choice == "custom":
94
- protocol_input = click.prompt(
95
- "Protocol family (for API compatibility)",
96
- type=click.Choice([p.value for p in ProviderType]),
97
- default=ProviderType.OPENAI_COMPATIBLE.value,
104
+ # Build protocol choice options
105
+ protocol_options = [
106
+ ChoiceOption(
107
+ p.value,
108
+ p.value, # Plain text
109
+ is_default=(p == ProviderType.OPENAI_COMPATIBLE),
110
+ )
111
+ for p in ProviderType
112
+ ]
113
+
114
+ protocol_input = prompt_choice(
115
+ message="Choose the protocol family (for API compatibility)",
116
+ options=protocol_options,
117
+ title="Custom Provider - Protocol Selection",
118
+ allow_esc=True,
119
+ esc_value=ProviderType.OPENAI_COMPATIBLE.value,
120
+ style=onboarding_style(),
98
121
  )
99
122
  protocol = ProviderType(protocol_input)
100
- api_base_override = click.prompt("API Base URL")
123
+
124
+ # Get API base URL - use click.prompt for free-form text input
125
+ console.print("\n[dim]Now we need your API Base URL.[/dim]")
126
+ api_base_override = click.prompt("API Base URL").strip()
127
+
101
128
  provider_option = ProviderOption(
102
129
  key="custom",
103
130
  protocol=protocol,
@@ -160,29 +187,55 @@ def get_model_name_with_suggestions(
160
187
  default_model = provider.default_model or default_model_for_protocol(provider.protocol)
161
188
  suggestions = list(provider.model_suggestions)
162
189
 
163
- # Show suggestions if available
190
+ # Prompt for model name using unified choice component if suggestions exist
164
191
  if suggestions:
165
- console.print("\n[dim]Available models for this provider:[/dim]")
166
- for i, model_name in enumerate(suggestions[:5]): # Show top 5
167
- console.print(f" [dim]{i + 1}. {model_name}[/dim]")
168
- console.print("")
169
-
170
- # Prompt for model name
171
- if provider.protocol == ProviderType.ANTHROPIC:
172
- model = click.prompt("Model name", default=default_model)
173
- elif provider.protocol == ProviderType.OPENAI_COMPATIBLE:
174
- model = click.prompt("Model name", default=default_model)
175
- # Prompt for API base if still not set
176
- if api_base is None:
177
- api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
178
- api_base = api_base_input or None
179
- elif provider.protocol == ProviderType.GEMINI:
180
- model = click.prompt("Model name", default=default_model)
181
- if api_base is None:
182
- api_base_input = click.prompt("API base URL (optional)", default="", show_default=False)
183
- api_base = api_base_input or None
192
+ # Create choice options for models
193
+ model_options = [
194
+ ChoiceOption(model, model) # Plain text
195
+ for model in suggestions[:5] # Show top 5
196
+ ]
197
+ # Add custom option
198
+ model_options.append(ChoiceOption("custom", "<dim>Custom model...</dim>"))
199
+
200
+ model = prompt_choice(
201
+ message="Select a model or choose 'Custom' to enter manually",
202
+ options=model_options,
203
+ title="Available models for this provider",
204
+ description=f"Default: {default_model}",
205
+ allow_esc=True,
206
+ esc_value=default_model,
207
+ style=onboarding_style(),
208
+ )
209
+
210
+ # If user chose custom, prompt for manual input using click.prompt
211
+ if model == "custom":
212
+ console.print("\n[dim]Enter your custom model name:[/dim]")
213
+ model = click.prompt("Model name", default=default_model).strip()
184
214
  else:
185
- model = click.prompt("Model name", default=default_model)
215
+ # No suggestions, use default
216
+ model = default_model
217
+
218
+ # Prompt for API base if still not set (for OpenAI-compatible providers)
219
+ if provider.protocol == ProviderType.OPENAI_COMPATIBLE and api_base is None:
220
+ api_base_input = prompt_choice(
221
+ message="Enter API base URL (or press Enter to skip)",
222
+ options=[ChoiceOption("", "<dim>Skip</dim>")],
223
+ title="API base URL (optional)",
224
+ allow_esc=True,
225
+ esc_value="",
226
+ style=onboarding_style(),
227
+ )
228
+ api_base = api_base_input or None
229
+ elif provider.protocol == ProviderType.GEMINI and api_base is None:
230
+ api_base_input = prompt_choice(
231
+ message="Enter API base URL (or press Enter to skip)",
232
+ options=[ChoiceOption("", "<dim>Skip</dim>")],
233
+ title="API base URL (optional)",
234
+ allow_esc=True,
235
+ esc_value="",
236
+ style=onboarding_style(),
237
+ )
238
+ api_base = api_base_input or None
186
239
 
187
240
  return model, api_base
188
241
 
ripperdoc/core/config.py CHANGED
@@ -230,6 +230,7 @@ class GlobalConfig(BaseModel):
230
230
  # User-level permission rules (applied globally)
231
231
  user_allow_rules: list[str] = Field(default_factory=list)
232
232
  user_deny_rules: list[str] = Field(default_factory=list)
233
+ user_ask_rules: list[str] = Field(default_factory=list)
233
234
 
234
235
  # Onboarding
235
236
  has_completed_onboarding: bool = False
@@ -258,6 +259,7 @@ class ProjectConfig(BaseModel):
258
259
  allowed_tools: list[str] = Field(default_factory=list)
259
260
  bash_allow_rules: list[str] = Field(default_factory=list)
260
261
  bash_deny_rules: list[str] = Field(default_factory=list)
262
+ bash_ask_rules: list[str] = Field(default_factory=list)
261
263
  working_directories: list[str] = Field(default_factory=list)
262
264
 
263
265
  # Path ignore patterns (gitignore-style)
@@ -291,6 +293,7 @@ class ProjectLocalConfig(BaseModel):
291
293
  # Local permission rules (project-specific but not shared)
292
294
  local_allow_rules: list[str] = Field(default_factory=list)
293
295
  local_deny_rules: list[str] = Field(default_factory=list)
296
+ local_ask_rules: list[str] = Field(default_factory=list)
294
297
 
295
298
 
296
299
  class ConfigManager:
@@ -9,12 +9,7 @@ from dataclasses import dataclass
9
9
  from pathlib import Path
10
10
  from typing import Any, Awaitable, Callable, Optional, Set, TYPE_CHECKING, TYPE_CHECKING as TYPE_CHECKING
11
11
 
12
- from prompt_toolkit.filters import is_done
13
- from prompt_toolkit.formatted_text import HTML
14
- from prompt_toolkit.key_binding import KeyBindings
15
- from prompt_toolkit.shortcuts import choice
16
- from prompt_toolkit.styles import Style
17
-
12
+ from ripperdoc.cli.ui.choice import prompt_choice
18
13
  from ripperdoc.core.config import config_manager
19
14
  from ripperdoc.core.hooks.manager import hook_manager
20
15
  from ripperdoc.core.tool import Tool
@@ -101,25 +96,6 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
101
96
  return tool.name
102
97
 
103
98
 
104
- def _permission_style() -> Style:
105
- """Create the style for permission choice prompts."""
106
- return Style.from_dict(
107
- {
108
- "frame.border": "#d4a017", # Golden/amber border
109
- "selected-option": "bold",
110
- "option": "#5fd7ff", # Cyan for unselected options
111
- "title": "#ffaf00", # Orange/amber for tool name
112
- "description": "#ffffff", # White for descriptions
113
- "question": "#ffd700", # Gold for the question
114
- "label": "#87afff", # Light blue for field labels (Command:, Sandbox:, etc.)
115
- "warning": "#ff5555", # Red for warnings
116
- "yes-option": "#ffffff", # Neutral for Yes options
117
- "no-option": "#ffffff", # Neutral for No option
118
- "value": "#f8f8f2", # Off-white for values
119
- }
120
- )
121
-
122
-
123
99
  def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
124
100
  """Normalize rule suggestions to simple strings."""
125
101
  if not rule_suggestions:
@@ -137,8 +113,8 @@ def make_permission_checker(
137
113
  project_path: Path,
138
114
  yolo_mode: bool,
139
115
  prompt_fn: Optional[Callable[[str], str]] = None,
140
- console: Optional["Console"] = None,
141
- 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)
142
118
  ) -> Callable[[Tool[Any, Any], Any], Awaitable[PermissionResult]]:
143
119
  """Create a permission checking function for the current project.
144
120
 
@@ -146,25 +122,25 @@ def make_permission_checker(
146
122
  project_path: Path to the project directory
147
123
  yolo_mode: If True, all tool calls are allowed without prompting
148
124
  prompt_fn: Optional function to use for prompting (defaults to input())
149
- console: Optional Rich console for rich permission dialogs
150
- 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
151
127
 
152
128
  In yolo mode, all tool calls are allowed without prompting.
153
129
  """
154
130
 
131
+ _ = console, prompt_session # Mark as intentionally unused
155
132
  project_path = project_path.resolve()
156
133
  config_manager.get_project_config(project_path)
157
134
 
158
135
  session_allowed_tools: Set[str] = set()
159
136
  session_tool_rules: dict[str, Set[str]] = defaultdict(set)
160
137
 
161
- async def _prompt_user(prompt: str, options: list[tuple[str, str]], is_html: bool = False) -> str:
162
- """Prompt the user with proper interrupt handling using choice().
138
+ async def _prompt_user(prompt: str, options: list[tuple[str, str]]) -> str:
139
+ """Prompt the user with proper interrupt handling using unified choice component.
163
140
 
164
141
  Args:
165
- prompt: The prompt text to display.
142
+ prompt: The prompt text to display (supports HTML formatting).
166
143
  options: List of (value, label) tuples for choices.
167
- is_html: If True, prompt is already formatted HTML and should not be escaped.
168
144
  """
169
145
  loop = asyncio.get_running_loop()
170
146
 
@@ -179,42 +155,13 @@ def make_permission_checker(
179
155
  input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
180
156
  return responder(input_prompt)
181
157
 
182
- # Convert options to choice() format (value, label)
183
- # Labels can be HTML-formatted strings
184
- choice_options = [(value, HTML(label) if "<" in label else label) for value, label in options]
185
-
186
- # Build formatted message with prompt text
187
- # Add visual separation with lines
188
- if is_html:
189
- # Prompt is already HTML formatted
190
- formatted_prompt = HTML(f"\n{prompt}\n")
191
- else:
192
- # Escape HTML special characters in plain text prompt
193
- formatted_prompt = HTML(f"\n{html.escape(prompt)}\n")
194
-
195
- esc_bindings = KeyBindings()
196
-
197
- @esc_bindings.add("escape", eager=True)
198
- def _esc_to_deny(event: Any) -> None:
199
- event.app.exit(result="n", style="class:aborting")
200
-
201
- result = choice(
202
- message=formatted_prompt,
203
- options=choice_options,
204
- style=_permission_style(),
205
- show_frame=~is_done, # Frame disappears after selection
206
- key_bindings=esc_bindings,
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
207
164
  )
208
-
209
- # Clear the entire prompt after selection
210
- # ANSI codes: ESC[F = move cursor to beginning of previous line
211
- # ESC[2K = clear entire line
212
- # We need to clear: blank + prompt + blank + options (each option takes 1 line)
213
- # plus frame borders (top and bottom) = approximately 6-8 lines
214
- for _ in range(12): # Clear enough lines to cover the prompt
215
- print("\033[F\033[2K", end="", flush=True)
216
-
217
- return result
218
165
  except KeyboardInterrupt:
219
166
  logger.debug("[permissions] KeyboardInterrupt in choice")
220
167
  return "n"
@@ -270,6 +217,13 @@ def make_permission_checker(
270
217
  | set(local_config.local_deny_rules or [])
271
218
  )
272
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
+ }
273
227
  allowed_working_dirs = {
274
228
  str(project_path.resolve()),
275
229
  *[str(Path(p).resolve()) for p in config.working_directories or []],
@@ -287,6 +241,7 @@ def make_permission_checker(
287
241
  {
288
242
  "allowed_rules": allow_rules.get(tool.name, set()),
289
243
  "denied_rules": deny_rules.get(tool.name, set()),
244
+ "ask_rules": ask_rules.get(tool.name, set()),
290
245
  "allowed_working_directories": allowed_working_dirs,
291
246
  },
292
247
  )
@@ -321,7 +276,7 @@ def make_permission_checker(
321
276
 
322
277
  # If tool doesn't normally require permission (e.g., read-only Bash),
323
278
  # enforce deny rules but otherwise skip prompting.
324
- if not needs_permission:
279
+ if not needs_permission and decision.behavior != "ask":
325
280
  if decision.behavior == "deny":
326
281
  return PermissionResult(
327
282
  result=False,
@@ -410,7 +365,7 @@ def make_permission_checker(
410
365
  ("n", "<no-option>No</no-option>"),
411
366
  ]
412
367
 
413
- answer = (await _prompt_user(prompt, options=options, is_html=True)).strip().lower()
368
+ answer = (await _prompt_user(prompt, options=options)).strip().lower()
414
369
  logger.debug(
415
370
  "[permissions] User answer for permission prompt",
416
371
  extra={"answer": answer, "tool": getattr(tool, "name", None)},
@@ -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