ripperdoc 0.2.7__py3-none-any.whl → 0.2.9__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 (87) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +33 -115
  3. ripperdoc/cli/commands/__init__.py +70 -6
  4. ripperdoc/cli/commands/agents_cmd.py +6 -3
  5. ripperdoc/cli/commands/clear_cmd.py +1 -4
  6. ripperdoc/cli/commands/config_cmd.py +1 -1
  7. ripperdoc/cli/commands/context_cmd.py +3 -2
  8. ripperdoc/cli/commands/doctor_cmd.py +18 -4
  9. ripperdoc/cli/commands/help_cmd.py +11 -1
  10. ripperdoc/cli/commands/hooks_cmd.py +610 -0
  11. ripperdoc/cli/commands/models_cmd.py +26 -9
  12. ripperdoc/cli/commands/permissions_cmd.py +57 -37
  13. ripperdoc/cli/commands/resume_cmd.py +6 -4
  14. ripperdoc/cli/commands/status_cmd.py +4 -4
  15. ripperdoc/cli/commands/tasks_cmd.py +8 -4
  16. ripperdoc/cli/ui/file_mention_completer.py +64 -8
  17. ripperdoc/cli/ui/interrupt_handler.py +3 -4
  18. ripperdoc/cli/ui/message_display.py +5 -3
  19. ripperdoc/cli/ui/panels.py +13 -10
  20. ripperdoc/cli/ui/provider_options.py +247 -0
  21. ripperdoc/cli/ui/rich_ui.py +196 -77
  22. ripperdoc/cli/ui/spinner.py +25 -1
  23. ripperdoc/cli/ui/tool_renderers.py +8 -2
  24. ripperdoc/cli/ui/wizard.py +215 -0
  25. ripperdoc/core/agents.py +9 -3
  26. ripperdoc/core/config.py +49 -12
  27. ripperdoc/core/custom_commands.py +412 -0
  28. ripperdoc/core/default_tools.py +11 -2
  29. ripperdoc/core/hooks/__init__.py +99 -0
  30. ripperdoc/core/hooks/config.py +301 -0
  31. ripperdoc/core/hooks/events.py +535 -0
  32. ripperdoc/core/hooks/executor.py +496 -0
  33. ripperdoc/core/hooks/integration.py +344 -0
  34. ripperdoc/core/hooks/manager.py +745 -0
  35. ripperdoc/core/permissions.py +40 -8
  36. ripperdoc/core/providers/anthropic.py +548 -68
  37. ripperdoc/core/providers/gemini.py +70 -5
  38. ripperdoc/core/providers/openai.py +60 -5
  39. ripperdoc/core/query.py +140 -39
  40. ripperdoc/core/query_utils.py +2 -0
  41. ripperdoc/core/skills.py +9 -3
  42. ripperdoc/core/system_prompt.py +4 -2
  43. ripperdoc/core/tool.py +9 -5
  44. ripperdoc/sdk/client.py +2 -2
  45. ripperdoc/tools/ask_user_question_tool.py +5 -3
  46. ripperdoc/tools/background_shell.py +2 -1
  47. ripperdoc/tools/bash_output_tool.py +1 -1
  48. ripperdoc/tools/bash_tool.py +30 -20
  49. ripperdoc/tools/dynamic_mcp_tool.py +29 -8
  50. ripperdoc/tools/enter_plan_mode_tool.py +1 -1
  51. ripperdoc/tools/exit_plan_mode_tool.py +1 -1
  52. ripperdoc/tools/file_edit_tool.py +8 -4
  53. ripperdoc/tools/file_read_tool.py +9 -5
  54. ripperdoc/tools/file_write_tool.py +9 -5
  55. ripperdoc/tools/glob_tool.py +3 -2
  56. ripperdoc/tools/grep_tool.py +3 -2
  57. ripperdoc/tools/kill_bash_tool.py +1 -1
  58. ripperdoc/tools/ls_tool.py +1 -1
  59. ripperdoc/tools/mcp_tools.py +13 -10
  60. ripperdoc/tools/multi_edit_tool.py +8 -7
  61. ripperdoc/tools/notebook_edit_tool.py +7 -4
  62. ripperdoc/tools/skill_tool.py +1 -1
  63. ripperdoc/tools/task_tool.py +5 -4
  64. ripperdoc/tools/todo_tool.py +2 -2
  65. ripperdoc/tools/tool_search_tool.py +3 -2
  66. ripperdoc/utils/conversation_compaction.py +11 -7
  67. ripperdoc/utils/file_watch.py +8 -2
  68. ripperdoc/utils/json_utils.py +2 -1
  69. ripperdoc/utils/mcp.py +11 -3
  70. ripperdoc/utils/memory.py +4 -2
  71. ripperdoc/utils/message_compaction.py +21 -7
  72. ripperdoc/utils/message_formatting.py +11 -7
  73. ripperdoc/utils/messages.py +105 -66
  74. ripperdoc/utils/path_ignore.py +38 -12
  75. ripperdoc/utils/permissions/path_validation_utils.py +2 -1
  76. ripperdoc/utils/permissions/shell_command_validation.py +427 -91
  77. ripperdoc/utils/safe_get_cwd.py +2 -1
  78. ripperdoc/utils/session_history.py +13 -6
  79. ripperdoc/utils/todo.py +2 -1
  80. ripperdoc/utils/token_estimation.py +6 -1
  81. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/METADATA +24 -3
  82. ripperdoc-0.2.9.dist-info/RECORD +123 -0
  83. ripperdoc-0.2.7.dist-info/RECORD +0 -113
  84. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/WHEEL +0 -0
  85. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/entry_points.txt +0 -0
  86. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/licenses/LICENSE +0 -0
  87. {ripperdoc-0.2.7.dist-info → ripperdoc-0.2.9.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,215 @@
1
+ """
2
+ Interactive onboarding wizard for Ripperdoc.
3
+ """
4
+
5
+ from typing import List, Optional, Tuple
6
+
7
+ import click
8
+ from rich.console import Console
9
+
10
+ from ripperdoc.cli.ui.provider_options import (
11
+ KNOWN_PROVIDERS,
12
+ ProviderOption,
13
+ default_model_for_protocol,
14
+ )
15
+ from ripperdoc.core.config import (
16
+ GlobalConfig,
17
+ ModelProfile,
18
+ ProviderType,
19
+ get_global_config,
20
+ save_global_config,
21
+ )
22
+ from ripperdoc.utils.prompt import prompt_secret
23
+
24
+
25
+ console = Console()
26
+
27
+
28
+ def resolve_provider_choice(raw_choice: str, provider_keys: List[str]) -> Optional[str]:
29
+ """Normalize user input into a provider key."""
30
+ normalized = raw_choice.strip().lower()
31
+ if normalized in provider_keys:
32
+ return normalized
33
+ try:
34
+ idx = int(normalized)
35
+ if 1 <= idx <= len(provider_keys):
36
+ return provider_keys[idx - 1]
37
+ except ValueError:
38
+ return None
39
+ return None
40
+
41
+
42
+ def check_onboarding() -> bool:
43
+ """Check if onboarding is complete and run if needed."""
44
+ config = get_global_config()
45
+
46
+ if config.has_completed_onboarding:
47
+ return True
48
+
49
+ console.print("[bold cyan]Welcome to Ripperdoc![/bold cyan]\n")
50
+ console.print("Let's set up your AI model configuration.\n")
51
+
52
+ return run_onboarding_wizard(config)
53
+
54
+
55
+ def run_onboarding_wizard(config: GlobalConfig) -> bool:
56
+ """Run interactive onboarding wizard."""
57
+ provider_keys = KNOWN_PROVIDERS.keys() + ["custom"]
58
+ default_choice_key = KNOWN_PROVIDERS.default_choice.key
59
+
60
+ # Display provider options vertically
61
+ console.print("[bold]Available providers:[/bold]")
62
+ for i, provider_key in enumerate(provider_keys, 1):
63
+ marker = "[cyan]→[/cyan]" if provider_key == default_choice_key else " "
64
+ console.print(f" {marker} {i}. {provider_key}")
65
+ console.print("")
66
+
67
+ # Prompt for provider choice with validation
68
+ provider_choice: Optional[str] = None
69
+ while provider_choice is None:
70
+ raw_choice = click.prompt(
71
+ "Choose your model provider",
72
+ default=default_choice_key,
73
+ )
74
+ provider_choice = resolve_provider_choice(raw_choice, provider_keys)
75
+ if provider_choice is None:
76
+ console.print(
77
+ f"[red]Invalid choice. Please enter a provider name or number (1-{len(provider_keys)}).[/red]"
78
+ )
79
+
80
+ api_base_override: Optional[str] = None
81
+ if provider_choice == "custom":
82
+ protocol_input = click.prompt(
83
+ "Protocol family (for API compatibility)",
84
+ type=click.Choice([p.value for p in ProviderType]),
85
+ default=ProviderType.OPENAI_COMPATIBLE.value,
86
+ )
87
+ protocol = ProviderType(protocol_input)
88
+ api_base_override = click.prompt("API Base URL")
89
+ provider_option = ProviderOption(
90
+ key="custom",
91
+ protocol=protocol,
92
+ default_model=default_model_for_protocol(protocol),
93
+ model_suggestions=(),
94
+ )
95
+ else:
96
+ provider_option = KNOWN_PROVIDERS.get(provider_choice)
97
+ if provider_option is None:
98
+ provider_option = ProviderOption(
99
+ key=provider_choice,
100
+ protocol=ProviderType.OPENAI_COMPATIBLE,
101
+ default_model=default_model_for_protocol(ProviderType.OPENAI_COMPATIBLE),
102
+ model_suggestions=(),
103
+ )
104
+
105
+ api_key = ""
106
+ while not api_key:
107
+ api_key = prompt_secret("Enter your API key").strip()
108
+ if not api_key:
109
+ console.print("[red]API key is required.[/red]")
110
+
111
+ # Get model name with provider-specific suggestions
112
+ model, api_base = get_model_name_with_suggestions(provider_option, api_base_override)
113
+
114
+ # Get context window
115
+ context_window = get_context_window()
116
+
117
+ # Create model profile
118
+ config.model_profiles["default"] = ModelProfile(
119
+ provider=provider_option.protocol,
120
+ model=model,
121
+ api_key=api_key,
122
+ api_base=api_base,
123
+ context_window=context_window,
124
+ )
125
+
126
+ config.has_completed_onboarding = True
127
+ config.last_onboarding_version = get_version()
128
+
129
+ save_global_config(config)
130
+
131
+ console.print("\n[green]✓ Configuration saved![/green]\n")
132
+ return True
133
+
134
+
135
+ def get_model_name_with_suggestions(
136
+ provider: ProviderOption,
137
+ api_base_override: Optional[str],
138
+ ) -> Tuple[str, Optional[str]]:
139
+ """Get model name with provider-specific suggestions and default API base.
140
+
141
+ Returns:
142
+ Tuple of (model_name, api_base)
143
+ """
144
+ # Set default API base based on provider choice
145
+ api_base = api_base_override
146
+ if api_base is None and provider.default_api_base:
147
+ api_base = provider.default_api_base
148
+ console.print(f"[dim]Using default API base: {api_base}[/dim]")
149
+
150
+ default_model = provider.default_model or default_model_for_protocol(provider.protocol)
151
+ suggestions = list(provider.model_suggestions)
152
+
153
+ # Show suggestions if available
154
+ if suggestions:
155
+ console.print("\n[dim]Available models for this provider:[/dim]")
156
+ for i, model_name in enumerate(suggestions[:5]): # Show top 5
157
+ console.print(f" [dim]{i+1}. {model_name}[/dim]")
158
+ console.print("")
159
+
160
+ # Prompt for model name
161
+ if provider.protocol == ProviderType.ANTHROPIC:
162
+ model = click.prompt("Model name", default=default_model)
163
+ elif provider.protocol == ProviderType.OPENAI_COMPATIBLE:
164
+ model = click.prompt("Model name", default=default_model)
165
+ # Prompt for API base if still not set
166
+ if api_base is None:
167
+ api_base_input = click.prompt(
168
+ "API base URL (optional)", default="", show_default=False
169
+ )
170
+ api_base = api_base_input or None
171
+ elif provider.protocol == ProviderType.GEMINI:
172
+ model = click.prompt("Model name", default=default_model)
173
+ if api_base is None:
174
+ api_base_input = click.prompt(
175
+ "API base URL (optional)", default="", show_default=False
176
+ )
177
+ api_base = api_base_input or None
178
+ else:
179
+ model = click.prompt("Model name", default=default_model)
180
+
181
+ return model, api_base
182
+
183
+
184
+ def get_context_window() -> Optional[int]:
185
+ """Get context window size from user."""
186
+ context_window_input = click.prompt(
187
+ "Context window in tokens (optional, press Enter to skip)",
188
+ default="",
189
+ show_default=False,
190
+ )
191
+ context_window = None
192
+ if context_window_input.strip():
193
+ try:
194
+ context_window = int(context_window_input.strip())
195
+ except ValueError:
196
+ console.print(
197
+ "[yellow]Invalid context window, using auto-detected defaults.[/yellow]"
198
+ )
199
+ return context_window
200
+
201
+
202
+ def get_version() -> str:
203
+ """Get current version of Ripperdoc."""
204
+ try:
205
+ from ripperdoc import __version__
206
+ return __version__
207
+ except ImportError:
208
+ return "unknown"
209
+
210
+
211
+ if __name__ == "__main__":
212
+ # For testing
213
+ config = get_global_config()
214
+ config.has_completed_onboarding = False
215
+ run_onboarding_wizard(config)
ripperdoc/core/agents.py CHANGED
@@ -278,10 +278,15 @@ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
278
278
  body = "\n".join(lines[idx + 1 :])
279
279
  try:
280
280
  frontmatter = yaml.safe_load(frontmatter_text) or {}
281
- except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
281
+ except (
282
+ yaml.YAMLError,
283
+ ValueError,
284
+ TypeError,
285
+ ) as exc: # pragma: no cover - defensive
282
286
  logger.warning(
283
287
  "Invalid frontmatter in agent file: %s: %s",
284
- type(exc).__name__, exc,
288
+ type(exc).__name__,
289
+ exc,
285
290
  extra={"error": str(exc)},
286
291
  )
287
292
  return {"__error__": f"Invalid frontmatter: {exc}"}, body
@@ -312,7 +317,8 @@ def _parse_agent_file(
312
317
  except (OSError, IOError, UnicodeDecodeError) as exc:
313
318
  logger.warning(
314
319
  "Failed to read agent file: %s: %s",
315
- type(exc).__name__, exc,
320
+ type(exc).__name__,
321
+ exc,
316
322
  extra={"error": str(exc), "path": str(path)},
317
323
  )
318
324
  return None, f"Failed to read agent file {path}: {exc}"
ripperdoc/core/config.py CHANGED
@@ -7,8 +7,8 @@ including API keys, model settings, and user preferences.
7
7
  import json
8
8
  import os
9
9
  from pathlib import Path
10
- from typing import Dict, Optional, Literal
11
- from pydantic import BaseModel, Field
10
+ from typing import Any, Dict, Optional, Literal
11
+ from pydantic import BaseModel, Field, field_validator, model_validator
12
12
  from enum import Enum
13
13
 
14
14
  from ripperdoc.utils.log import get_logger
@@ -111,7 +111,7 @@ class ModelProfile(BaseModel):
111
111
  # interactions into plain text to support providers that reject tool roles.
112
112
  openai_tool_mode: Literal["native", "text"] = "native"
113
113
  # Optional override for thinking protocol handling (e.g., "deepseek", "openrouter",
114
- # "qwen", "gemini_openai", "openai_reasoning"). When unset, provider heuristics are used.
114
+ # "qwen", "gemini_openai", "openai"). When unset, provider heuristics are used.
115
115
  thinking_mode: Optional[str] = None
116
116
  # Pricing (USD per 1M tokens). Leave as 0 to skip cost calculation.
117
117
  input_cost_per_million_tokens: float = 0.0
@@ -130,7 +130,7 @@ class ModelPointers(BaseModel):
130
130
  class GlobalConfig(BaseModel):
131
131
  """Global configuration stored in ~/.ripperdoc.json"""
132
132
 
133
- model_config = {"protected_namespaces": ()}
133
+ model_config = {"protected_namespaces": (), "populate_by_name": True}
134
134
 
135
135
  # Model configuration
136
136
  model_profiles: Dict[str, ModelProfile] = Field(default_factory=dict)
@@ -139,7 +139,8 @@ class GlobalConfig(BaseModel):
139
139
  # User preferences
140
140
  theme: str = "dark"
141
141
  verbose: bool = False
142
- safe_mode: bool = True
142
+ yolo_mode: bool = Field(default=False)
143
+ show_full_thinking: bool = Field(default=False)
143
144
  auto_compact_enabled: bool = True
144
145
  context_token_limit: Optional[int] = None
145
146
 
@@ -154,6 +155,18 @@ class GlobalConfig(BaseModel):
154
155
  # Statistics
155
156
  num_startups: int = 0
156
157
 
158
+ @model_validator(mode="before")
159
+ @classmethod
160
+ def _migrate_safe_mode(cls, data: Any) -> Any:
161
+ """Translate legacy safe_mode to the new yolo_mode flag."""
162
+ if isinstance(data, dict) and "safe_mode" in data and "yolo_mode" not in data:
163
+ data = dict(data)
164
+ try:
165
+ data["yolo_mode"] = not bool(data.pop("safe_mode"))
166
+ except Exception:
167
+ data["yolo_mode"] = False
168
+ return data
169
+
157
170
 
158
171
  class ProjectConfig(BaseModel):
159
172
  """Project-specific configuration stored in .ripperdoc/config.json"""
@@ -167,7 +180,7 @@ class ProjectConfig(BaseModel):
167
180
  # Path ignore patterns (gitignore-style)
168
181
  ignore_patterns: list[str] = Field(
169
182
  default_factory=list,
170
- description="Gitignore-style patterns for paths to ignore in file operations"
183
+ description="Gitignore-style patterns for paths to ignore in file operations",
171
184
  )
172
185
 
173
186
  # Context
@@ -222,10 +235,18 @@ class ConfigManager:
222
235
  "profile_count": len(self._global_config.model_profiles),
223
236
  },
224
237
  )
225
- except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
238
+ except (
239
+ json.JSONDecodeError,
240
+ OSError,
241
+ IOError,
242
+ UnicodeDecodeError,
243
+ ValueError,
244
+ TypeError,
245
+ ) as e:
226
246
  logger.warning(
227
247
  "Error loading global config: %s: %s",
228
- type(e).__name__, e,
248
+ type(e).__name__,
249
+ e,
229
250
  extra={"error": str(e)},
230
251
  )
231
252
  self._global_config = GlobalConfig()
@@ -276,10 +297,18 @@ class ConfigManager:
276
297
  "allowed_tools": len(self._project_config.allowed_tools),
277
298
  },
278
299
  )
279
- except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
300
+ except (
301
+ json.JSONDecodeError,
302
+ OSError,
303
+ IOError,
304
+ UnicodeDecodeError,
305
+ ValueError,
306
+ TypeError,
307
+ ) as e:
280
308
  logger.warning(
281
309
  "Error loading project config: %s: %s",
282
- type(e).__name__, e,
310
+ type(e).__name__,
311
+ e,
283
312
  extra={"error": str(e), "path": str(config_path)},
284
313
  )
285
314
  self._project_config = ProjectConfig()
@@ -344,10 +373,18 @@ class ConfigManager:
344
373
  "project_path": str(self.current_project_path),
345
374
  },
346
375
  )
347
- except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
376
+ except (
377
+ json.JSONDecodeError,
378
+ OSError,
379
+ IOError,
380
+ UnicodeDecodeError,
381
+ ValueError,
382
+ TypeError,
383
+ ) as e:
348
384
  logger.warning(
349
385
  "Error loading project-local config: %s: %s",
350
- type(e).__name__, e,
386
+ type(e).__name__,
387
+ e,
351
388
  extra={"error": str(e), "path": str(config_path)},
352
389
  )
353
390
  self._project_local_config = ProjectLocalConfig()