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,559 @@
1
+ """Configuration management for Ripperdoc.
2
+
3
+ This module handles global and project-specific configuration,
4
+ including API keys, model settings, and user preferences.
5
+ """
6
+
7
+ import json
8
+ import os
9
+ from pathlib import Path
10
+ from typing import Dict, Optional, Literal
11
+ from pydantic import BaseModel, Field
12
+ from enum import Enum
13
+
14
+ from ripperdoc.utils.log import get_logger
15
+
16
+
17
+ logger = get_logger()
18
+
19
+
20
+ class ProviderType(str, Enum):
21
+ """Supported model protocols (not individual model vendors)."""
22
+
23
+ ANTHROPIC = "anthropic"
24
+ OPENAI_COMPATIBLE = "openai_compatible"
25
+ GEMINI = "gemini"
26
+
27
+ @classmethod
28
+ def _legacy_aliases(cls) -> Dict[str, "ProviderType"]:
29
+ """Map legacy provider labels to protocol families."""
30
+ return {
31
+ "openai": cls.OPENAI_COMPATIBLE,
32
+ "openai-compatible": cls.OPENAI_COMPATIBLE,
33
+ "openai compatible": cls.OPENAI_COMPATIBLE,
34
+ "mistral": cls.OPENAI_COMPATIBLE,
35
+ "deepseek": cls.OPENAI_COMPATIBLE,
36
+ "kimi": cls.OPENAI_COMPATIBLE,
37
+ "qwen": cls.OPENAI_COMPATIBLE,
38
+ "glm": cls.OPENAI_COMPATIBLE,
39
+ "google": cls.GEMINI,
40
+ }
41
+
42
+ @classmethod
43
+ def _missing_(cls, value: object) -> Optional["ProviderType"]:
44
+ """Support legacy provider strings by mapping to their protocol."""
45
+ if isinstance(value, str):
46
+ normalized = value.strip().lower()
47
+ mapped = cls._legacy_aliases().get(normalized)
48
+ if mapped:
49
+ return mapped
50
+ return None
51
+
52
+
53
+ def provider_protocol(provider: ProviderType) -> str:
54
+ """Return the message formatting protocol for a provider."""
55
+ if provider == ProviderType.ANTHROPIC:
56
+ return "anthropic"
57
+ if provider == ProviderType.OPENAI_COMPATIBLE:
58
+ return "openai"
59
+ if provider == ProviderType.GEMINI:
60
+ # Gemini support is planned; default to OpenAI-style formatting for now.
61
+ return "openai"
62
+ return "openai"
63
+
64
+
65
+ def api_key_env_candidates(provider: ProviderType) -> list[str]:
66
+ """Environment variables to check for an API key based on protocol."""
67
+ if provider == ProviderType.ANTHROPIC:
68
+ return ["ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN"]
69
+ if provider == ProviderType.GEMINI:
70
+ return ["GEMINI_API_KEY", "GOOGLE_API_KEY"]
71
+ return [
72
+ "OPENAI_COMPATIBLE_API_KEY",
73
+ "OPENAI_API_KEY",
74
+ "DEEPSEEK_API_KEY",
75
+ "MISTRAL_API_KEY",
76
+ "KIMI_API_KEY",
77
+ "QWEN_API_KEY",
78
+ "GLM_API_KEY",
79
+ ]
80
+
81
+
82
+ def api_base_env_candidates(provider: ProviderType) -> list[str]:
83
+ """Environment variables to check for API base overrides."""
84
+ if provider == ProviderType.ANTHROPIC:
85
+ return ["ANTHROPIC_API_URL", "ANTHROPIC_BASE_URL"]
86
+ if provider == ProviderType.GEMINI:
87
+ return ["GEMINI_API_BASE", "GEMINI_BASE_URL", "GOOGLE_API_BASE_URL"]
88
+ return [
89
+ "OPENAI_COMPATIBLE_API_BASE",
90
+ "OPENAI_BASE_URL",
91
+ "OPENAI_API_BASE",
92
+ "DEEPSEEK_API_BASE",
93
+ "DEEPSEEK_BASE_URL",
94
+ ]
95
+
96
+
97
+ class ModelProfile(BaseModel):
98
+ """Configuration for a specific AI model."""
99
+
100
+ provider: ProviderType
101
+ model: str
102
+ api_key: Optional[str] = None
103
+ # Anthropic supports either api_key or auth_token; api_key takes precedence when both are set.
104
+ auth_token: Optional[str] = None
105
+ api_base: Optional[str] = None
106
+ max_tokens: int = 4096
107
+ temperature: float = 0.7
108
+ # Total context window in tokens (if known). Falls back to heuristics when unset.
109
+ context_window: Optional[int] = None
110
+ # Tool handling for OpenAI-compatible providers. "native" uses tool_calls, "text" flattens tool
111
+ # interactions into plain text to support providers that reject tool roles.
112
+ openai_tool_mode: Literal["native", "text"] = "native"
113
+ # Optional override for thinking protocol handling (e.g., "deepseek", "openrouter",
114
+ # "qwen", "gemini_openai", "openai_reasoning"). When unset, provider heuristics are used.
115
+ thinking_mode: Optional[str] = None
116
+ # Pricing (USD per 1M tokens). Leave as 0 to skip cost calculation.
117
+ input_cost_per_million_tokens: float = 0.0
118
+ output_cost_per_million_tokens: float = 0.0
119
+
120
+
121
+ class ModelPointers(BaseModel):
122
+ """Pointers to different model profiles for different purposes."""
123
+
124
+ main: str = "default"
125
+ task: str = "default"
126
+ reasoning: str = "default"
127
+ quick: str = "default"
128
+
129
+
130
+ class GlobalConfig(BaseModel):
131
+ """Global configuration stored in ~/.ripperdoc.json"""
132
+
133
+ model_config = {"protected_namespaces": ()}
134
+
135
+ # Model configuration
136
+ model_profiles: Dict[str, ModelProfile] = Field(default_factory=dict)
137
+ model_pointers: ModelPointers = Field(default_factory=ModelPointers)
138
+
139
+ # User preferences
140
+ theme: str = "dark"
141
+ verbose: bool = False
142
+ safe_mode: bool = True
143
+ auto_compact_enabled: bool = True
144
+ context_token_limit: Optional[int] = None
145
+
146
+ # User-level permission rules (applied globally)
147
+ user_allow_rules: list[str] = Field(default_factory=list)
148
+ user_deny_rules: list[str] = Field(default_factory=list)
149
+
150
+ # Onboarding
151
+ has_completed_onboarding: bool = False
152
+ last_onboarding_version: Optional[str] = None
153
+
154
+ # Statistics
155
+ num_startups: int = 0
156
+
157
+
158
+ class ProjectConfig(BaseModel):
159
+ """Project-specific configuration stored in .ripperdoc/config.json"""
160
+
161
+ # Tool permissions (project level - checked into git)
162
+ allowed_tools: list[str] = Field(default_factory=list)
163
+ bash_allow_rules: list[str] = Field(default_factory=list)
164
+ bash_deny_rules: list[str] = Field(default_factory=list)
165
+ working_directories: list[str] = Field(default_factory=list)
166
+
167
+ # Path ignore patterns (gitignore-style)
168
+ ignore_patterns: list[str] = Field(
169
+ default_factory=list,
170
+ description="Gitignore-style patterns for paths to ignore in file operations"
171
+ )
172
+
173
+ # Context
174
+ context: Dict[str, str] = Field(default_factory=dict)
175
+ context_files: list[str] = Field(default_factory=list)
176
+
177
+ # History
178
+ history: list[str] = Field(default_factory=list)
179
+
180
+ # Project settings
181
+ dont_crawl_directory: bool = False
182
+ enable_architect_tool: bool = False
183
+
184
+ # Trust
185
+ has_trust_dialog_accepted: bool = False
186
+
187
+ # Session tracking
188
+ last_cost: Optional[float] = None
189
+ last_duration: Optional[float] = None
190
+ last_session_id: Optional[str] = None
191
+
192
+
193
+ class ProjectLocalConfig(BaseModel):
194
+ """Project-local configuration stored in .ripperdoc/config.local.json (not checked into git)"""
195
+
196
+ # Local permission rules (project-specific but not shared)
197
+ local_allow_rules: list[str] = Field(default_factory=list)
198
+ local_deny_rules: list[str] = Field(default_factory=list)
199
+
200
+
201
+ class ConfigManager:
202
+ """Manages global and project-specific configuration."""
203
+
204
+ def __init__(self) -> None:
205
+ self.global_config_path = Path.home() / ".ripperdoc.json"
206
+ self.current_project_path: Optional[Path] = None
207
+ self._global_config: Optional[GlobalConfig] = None
208
+ self._project_config: Optional[ProjectConfig] = None
209
+ self._project_local_config: Optional[ProjectLocalConfig] = None
210
+
211
+ def get_global_config(self) -> GlobalConfig:
212
+ """Load and return global configuration."""
213
+ if self._global_config is None:
214
+ if self.global_config_path.exists():
215
+ try:
216
+ data = json.loads(self.global_config_path.read_text())
217
+ self._global_config = GlobalConfig(**data)
218
+ logger.debug(
219
+ "[config] Loaded global configuration",
220
+ extra={
221
+ "path": str(self.global_config_path),
222
+ "profile_count": len(self._global_config.model_profiles),
223
+ },
224
+ )
225
+ except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
226
+ logger.warning(
227
+ "Error loading global config: %s: %s",
228
+ type(e).__name__, e,
229
+ extra={"error": str(e)},
230
+ )
231
+ self._global_config = GlobalConfig()
232
+ else:
233
+ self._global_config = GlobalConfig()
234
+ logger.debug(
235
+ "[config] Global config not found; using defaults",
236
+ extra={"path": str(self.global_config_path)},
237
+ )
238
+ return self._global_config
239
+
240
+ def save_global_config(self, config: GlobalConfig) -> None:
241
+ """Save global configuration."""
242
+ self._global_config = config
243
+ self.global_config_path.write_text(config.model_dump_json(indent=2))
244
+ logger.debug(
245
+ "[config] Saved global configuration",
246
+ extra={
247
+ "path": str(self.global_config_path),
248
+ "profile_count": len(config.model_profiles),
249
+ "pointers": config.model_pointers.model_dump(),
250
+ },
251
+ )
252
+
253
+ def get_project_config(self, project_path: Optional[Path] = None) -> ProjectConfig:
254
+ """Load and return project configuration."""
255
+ if project_path is not None:
256
+ # Reset cached project config when switching projects
257
+ if self.current_project_path != project_path:
258
+ self._project_config = None
259
+ self.current_project_path = project_path
260
+
261
+ if self.current_project_path is None:
262
+ return ProjectConfig()
263
+
264
+ config_path = self.current_project_path / ".ripperdoc" / "config.json"
265
+
266
+ if self._project_config is None:
267
+ if config_path.exists():
268
+ try:
269
+ data = json.loads(config_path.read_text())
270
+ self._project_config = ProjectConfig(**data)
271
+ logger.debug(
272
+ "[config] Loaded project config",
273
+ extra={
274
+ "path": str(config_path),
275
+ "project_path": str(self.current_project_path),
276
+ "allowed_tools": len(self._project_config.allowed_tools),
277
+ },
278
+ )
279
+ except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
280
+ logger.warning(
281
+ "Error loading project config: %s: %s",
282
+ type(e).__name__, e,
283
+ extra={"error": str(e), "path": str(config_path)},
284
+ )
285
+ self._project_config = ProjectConfig()
286
+ else:
287
+ self._project_config = ProjectConfig()
288
+ logger.debug(
289
+ "[config] Project config not found; using defaults",
290
+ extra={
291
+ "path": str(config_path),
292
+ "project_path": str(self.current_project_path),
293
+ },
294
+ )
295
+
296
+ return self._project_config
297
+
298
+ def save_project_config(
299
+ self, config: ProjectConfig, project_path: Optional[Path] = None
300
+ ) -> None:
301
+ """Save project configuration."""
302
+ if project_path is not None:
303
+ self.current_project_path = project_path
304
+
305
+ if self.current_project_path is None:
306
+ return
307
+
308
+ config_dir = self.current_project_path / ".ripperdoc"
309
+ config_dir.mkdir(exist_ok=True)
310
+
311
+ config_path = config_dir / "config.json"
312
+ self._project_config = config
313
+ config_path.write_text(config.model_dump_json(indent=2))
314
+ logger.debug(
315
+ "[config] Saved project config",
316
+ extra={
317
+ "path": str(config_path),
318
+ "project_path": str(self.current_project_path),
319
+ "allowed_tools": len(config.allowed_tools),
320
+ },
321
+ )
322
+
323
+ def get_project_local_config(self, project_path: Optional[Path] = None) -> ProjectLocalConfig:
324
+ """Load and return project-local configuration (not checked into git)."""
325
+ if project_path is not None:
326
+ if self.current_project_path != project_path:
327
+ self._project_local_config = None
328
+ self.current_project_path = project_path
329
+
330
+ if self.current_project_path is None:
331
+ return ProjectLocalConfig()
332
+
333
+ config_path = self.current_project_path / ".ripperdoc" / "config.local.json"
334
+
335
+ if self._project_local_config is None:
336
+ if config_path.exists():
337
+ try:
338
+ data = json.loads(config_path.read_text())
339
+ self._project_local_config = ProjectLocalConfig(**data)
340
+ logger.debug(
341
+ "[config] Loaded project-local config",
342
+ extra={
343
+ "path": str(config_path),
344
+ "project_path": str(self.current_project_path),
345
+ },
346
+ )
347
+ except (json.JSONDecodeError, OSError, IOError, UnicodeDecodeError, ValueError, TypeError) as e:
348
+ logger.warning(
349
+ "Error loading project-local config: %s: %s",
350
+ type(e).__name__, e,
351
+ extra={"error": str(e), "path": str(config_path)},
352
+ )
353
+ self._project_local_config = ProjectLocalConfig()
354
+ else:
355
+ self._project_local_config = ProjectLocalConfig()
356
+
357
+ return self._project_local_config
358
+
359
+ def save_project_local_config(
360
+ self, config: ProjectLocalConfig, project_path: Optional[Path] = None
361
+ ) -> None:
362
+ """Save project-local configuration."""
363
+ if project_path is not None:
364
+ self.current_project_path = project_path
365
+
366
+ if self.current_project_path is None:
367
+ return
368
+
369
+ config_dir = self.current_project_path / ".ripperdoc"
370
+ config_dir.mkdir(exist_ok=True)
371
+
372
+ config_path = config_dir / "config.local.json"
373
+ self._project_local_config = config
374
+ config_path.write_text(config.model_dump_json(indent=2))
375
+
376
+ # Ensure config.local.json is in .gitignore
377
+ self._ensure_gitignore_entry("config.local.json")
378
+
379
+ logger.debug(
380
+ "[config] Saved project-local config",
381
+ extra={
382
+ "path": str(config_path),
383
+ "project_path": str(self.current_project_path),
384
+ },
385
+ )
386
+
387
+ def _ensure_gitignore_entry(self, entry: str) -> bool:
388
+ """Ensure an entry exists in .ripperdoc/.gitignore. Returns True if added."""
389
+ if self.current_project_path is None:
390
+ return False
391
+
392
+ gitignore_path = self.current_project_path / ".ripperdoc" / ".gitignore"
393
+ try:
394
+ text = ""
395
+ if gitignore_path.exists():
396
+ text = gitignore_path.read_text(encoding="utf-8", errors="ignore")
397
+ existing_lines = text.splitlines()
398
+ if entry in existing_lines:
399
+ return False
400
+ with gitignore_path.open("a", encoding="utf-8") as f:
401
+ if text and not text.endswith("\n"):
402
+ f.write("\n")
403
+ f.write(f"{entry}\n")
404
+ return True
405
+ except (OSError, IOError):
406
+ return False
407
+
408
+ def get_api_key(self, provider: ProviderType) -> Optional[str]:
409
+ """Get API key for a provider."""
410
+ # First check environment variables
411
+ env_candidates = api_key_env_candidates(provider)
412
+ provider_env = f"{provider.value.upper()}_API_KEY"
413
+ if provider_env not in env_candidates:
414
+ env_candidates.insert(0, provider_env)
415
+ for env_var in env_candidates:
416
+ if env_var in os.environ:
417
+ return os.environ[env_var]
418
+
419
+ # Then check global config
420
+ global_config = self.get_global_config()
421
+ for profile in global_config.model_profiles.values():
422
+ if profile.provider == provider and profile.api_key:
423
+ return profile.api_key
424
+
425
+ return None
426
+
427
+ def get_current_model_profile(self, pointer: str = "main") -> Optional[ModelProfile]:
428
+ """Get the current model profile for a given pointer."""
429
+ global_config = self.get_global_config()
430
+
431
+ # Get the profile name from the pointer
432
+ profile_name = getattr(global_config.model_pointers, pointer, "default")
433
+
434
+ # Return the profile
435
+ return global_config.model_profiles.get(profile_name)
436
+
437
+ def _fallback_profile_name(self, preferred: str = "default") -> str:
438
+ """Pick a valid profile name to use as a fallback for pointers."""
439
+ config = self.get_global_config()
440
+ if preferred in config.model_profiles:
441
+ return preferred
442
+ if config.model_profiles:
443
+ return next(iter(config.model_profiles.keys()))
444
+ return ""
445
+
446
+ def add_model_profile(
447
+ self,
448
+ name: str,
449
+ profile: ModelProfile,
450
+ overwrite: bool = False,
451
+ set_as_main: bool = False,
452
+ ) -> GlobalConfig:
453
+ """Add or replace a model profile and optionally set it as the main pointer."""
454
+ config = self.get_global_config()
455
+ if not overwrite and name in config.model_profiles:
456
+ raise ValueError(f"Model profile '{name}' already exists.")
457
+
458
+ config.model_profiles[name] = profile
459
+ current_main = getattr(config.model_pointers, "main", "")
460
+ if set_as_main or not current_main or current_main not in config.model_profiles:
461
+ config.model_pointers.main = name
462
+ self.save_global_config(config)
463
+ return config
464
+
465
+ def delete_model_profile(self, name: str) -> GlobalConfig:
466
+ """Delete a model profile and repair pointers that referenced it."""
467
+ config = self.get_global_config()
468
+ if name not in config.model_profiles:
469
+ raise KeyError(f"Model profile '{name}' does not exist.")
470
+
471
+ del config.model_profiles[name]
472
+
473
+ fallback = self._fallback_profile_name()
474
+ for pointer_field in ModelPointers.model_fields:
475
+ current = getattr(config.model_pointers, pointer_field, "")
476
+ if current == name:
477
+ setattr(config.model_pointers, pointer_field, fallback)
478
+
479
+ self.save_global_config(config)
480
+ return config
481
+
482
+ def set_model_pointer(self, pointer: str, profile_name: str) -> GlobalConfig:
483
+ """Point a logical model slot (e.g., main/task) to a profile name."""
484
+ if pointer not in ModelPointers.model_fields:
485
+ raise ValueError(f"Unknown model pointer '{pointer}'.")
486
+
487
+ config = self.get_global_config()
488
+ if profile_name not in config.model_profiles:
489
+ raise ValueError(f"Model profile '{profile_name}' does not exist.")
490
+
491
+ setattr(config.model_pointers, pointer, profile_name)
492
+ self.save_global_config(config)
493
+ return config
494
+
495
+
496
+ # Global instance
497
+ config_manager = ConfigManager()
498
+
499
+
500
+ def get_global_config() -> GlobalConfig:
501
+ """Get global configuration."""
502
+ return config_manager.get_global_config()
503
+
504
+
505
+ def save_global_config(config: GlobalConfig) -> None:
506
+ """Save global configuration."""
507
+ config_manager.save_global_config(config)
508
+
509
+
510
+ def get_project_config(project_path: Optional[Path] = None) -> ProjectConfig:
511
+ """Get project configuration."""
512
+ return config_manager.get_project_config(project_path)
513
+
514
+
515
+ def save_project_config(config: ProjectConfig, project_path: Optional[Path] = None) -> None:
516
+ """Save project configuration."""
517
+ config_manager.save_project_config(config, project_path)
518
+
519
+
520
+ def add_model_profile(
521
+ name: str,
522
+ profile: ModelProfile,
523
+ overwrite: bool = False,
524
+ set_as_main: bool = False,
525
+ ) -> GlobalConfig:
526
+ """Add or replace a model profile and persist the update."""
527
+ return config_manager.add_model_profile(
528
+ name,
529
+ profile,
530
+ overwrite=overwrite,
531
+ set_as_main=set_as_main,
532
+ )
533
+
534
+
535
+ def delete_model_profile(name: str) -> GlobalConfig:
536
+ """Remove a model profile and update pointers as needed."""
537
+ return config_manager.delete_model_profile(name)
538
+
539
+
540
+ def set_model_pointer(pointer: str, profile_name: str) -> GlobalConfig:
541
+ """Update a model pointer (e.g., main/task) to target a profile."""
542
+ return config_manager.set_model_pointer(pointer, profile_name)
543
+
544
+
545
+ def get_current_model_profile(pointer: str = "main") -> Optional[ModelProfile]:
546
+ """Convenience wrapper to fetch the active profile for a pointer."""
547
+ return config_manager.get_current_model_profile(pointer)
548
+
549
+
550
+ def get_project_local_config(project_path: Optional[Path] = None) -> ProjectLocalConfig:
551
+ """Get project-local configuration (not checked into git)."""
552
+ return config_manager.get_project_local_config(project_path)
553
+
554
+
555
+ def save_project_local_config(
556
+ config: ProjectLocalConfig, project_path: Optional[Path] = None
557
+ ) -> None:
558
+ """Save project-local configuration."""
559
+ config_manager.save_project_local_config(config, project_path)
@@ -0,0 +1,88 @@
1
+ """Shared factory for default tool instances."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Any, List
6
+
7
+ from ripperdoc.core.tool import Tool
8
+
9
+ from ripperdoc.tools.bash_tool import BashTool
10
+ from ripperdoc.tools.bash_output_tool import BashOutputTool
11
+ from ripperdoc.tools.kill_bash_tool import KillBashTool
12
+ from ripperdoc.tools.file_read_tool import FileReadTool
13
+ from ripperdoc.tools.file_edit_tool import FileEditTool
14
+ from ripperdoc.tools.multi_edit_tool import MultiEditTool
15
+ from ripperdoc.tools.notebook_edit_tool import NotebookEditTool
16
+ from ripperdoc.tools.file_write_tool import FileWriteTool
17
+ from ripperdoc.tools.glob_tool import GlobTool
18
+ from ripperdoc.tools.ls_tool import LSTool
19
+ from ripperdoc.tools.grep_tool import GrepTool
20
+ from ripperdoc.tools.skill_tool import SkillTool
21
+ from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
22
+ from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
23
+ from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
24
+ from ripperdoc.tools.exit_plan_mode_tool import ExitPlanModeTool
25
+ from ripperdoc.tools.task_tool import TaskTool
26
+ from ripperdoc.tools.tool_search_tool import ToolSearchTool
27
+ from ripperdoc.tools.mcp_tools import (
28
+ ListMcpResourcesTool,
29
+ ListMcpServersTool,
30
+ ReadMcpResourceTool,
31
+ load_dynamic_mcp_tools_sync,
32
+ )
33
+ from ripperdoc.utils.log import get_logger
34
+
35
+ logger = get_logger()
36
+
37
+
38
+ def get_default_tools() -> List[Tool[Any, Any]]:
39
+ """Construct the default tool set (base tools + Task subagent launcher)."""
40
+ base_tools: List[Tool[Any, Any]] = [
41
+ BashTool(),
42
+ BashOutputTool(),
43
+ KillBashTool(),
44
+ FileReadTool(),
45
+ FileEditTool(),
46
+ MultiEditTool(),
47
+ NotebookEditTool(),
48
+ FileWriteTool(),
49
+ GlobTool(),
50
+ LSTool(),
51
+ GrepTool(),
52
+ SkillTool(),
53
+ TodoReadTool(),
54
+ TodoWriteTool(),
55
+ AskUserQuestionTool(),
56
+ EnterPlanModeTool(),
57
+ ExitPlanModeTool(),
58
+ ToolSearchTool(),
59
+ ListMcpServersTool(),
60
+ ListMcpResourcesTool(),
61
+ ReadMcpResourceTool(),
62
+ ]
63
+ dynamic_tools: List[Tool[Any, Any]] = []
64
+ try:
65
+ mcp_tools = load_dynamic_mcp_tools_sync()
66
+ # Filter to ensure only Tool instances are added
67
+ for tool in mcp_tools:
68
+ if isinstance(tool, Tool):
69
+ base_tools.append(tool)
70
+ dynamic_tools.append(tool)
71
+ except (ImportError, ModuleNotFoundError, OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
72
+ # If MCP runtime is not available, continue with base tools only.
73
+ logger.warning(
74
+ "[default_tools] Failed to load dynamic MCP tools: %s: %s",
75
+ type(exc).__name__, exc,
76
+ )
77
+
78
+ task_tool = TaskTool(lambda: base_tools)
79
+ all_tools = base_tools + [task_tool]
80
+ logger.debug(
81
+ "[default_tools] Built tool inventory",
82
+ extra={
83
+ "base_tools": len(base_tools),
84
+ "dynamic_mcp_tools": len(dynamic_tools),
85
+ "total_tools": len(all_tools),
86
+ },
87
+ )
88
+ return all_tools