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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
ripperdoc/core/config.py
ADDED
|
@@ -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
|