ripperdoc 0.2.4__py3-none-any.whl → 0.2.5__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 (75) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/__main__.py +0 -5
  3. ripperdoc/cli/cli.py +37 -16
  4. ripperdoc/cli/commands/__init__.py +2 -0
  5. ripperdoc/cli/commands/agents_cmd.py +12 -9
  6. ripperdoc/cli/commands/compact_cmd.py +7 -3
  7. ripperdoc/cli/commands/context_cmd.py +33 -13
  8. ripperdoc/cli/commands/doctor_cmd.py +27 -14
  9. ripperdoc/cli/commands/exit_cmd.py +1 -1
  10. ripperdoc/cli/commands/mcp_cmd.py +13 -8
  11. ripperdoc/cli/commands/memory_cmd.py +5 -5
  12. ripperdoc/cli/commands/models_cmd.py +47 -16
  13. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  14. ripperdoc/cli/commands/resume_cmd.py +1 -2
  15. ripperdoc/cli/commands/tasks_cmd.py +24 -13
  16. ripperdoc/cli/ui/rich_ui.py +500 -406
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +17 -9
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +7 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/anthropic.py +107 -4
  23. ripperdoc/core/providers/base.py +33 -4
  24. ripperdoc/core/providers/gemini.py +169 -50
  25. ripperdoc/core/providers/openai.py +257 -23
  26. ripperdoc/core/query.py +294 -61
  27. ripperdoc/core/query_utils.py +50 -6
  28. ripperdoc/core/skills.py +295 -0
  29. ripperdoc/core/system_prompt.py +13 -7
  30. ripperdoc/core/tool.py +8 -6
  31. ripperdoc/sdk/client.py +14 -1
  32. ripperdoc/tools/ask_user_question_tool.py +20 -22
  33. ripperdoc/tools/background_shell.py +19 -13
  34. ripperdoc/tools/bash_tool.py +356 -209
  35. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  36. ripperdoc/tools/enter_plan_mode_tool.py +5 -2
  37. ripperdoc/tools/exit_plan_mode_tool.py +6 -3
  38. ripperdoc/tools/file_edit_tool.py +53 -10
  39. ripperdoc/tools/file_read_tool.py +17 -7
  40. ripperdoc/tools/file_write_tool.py +49 -13
  41. ripperdoc/tools/glob_tool.py +10 -9
  42. ripperdoc/tools/grep_tool.py +182 -51
  43. ripperdoc/tools/ls_tool.py +6 -6
  44. ripperdoc/tools/mcp_tools.py +106 -456
  45. ripperdoc/tools/multi_edit_tool.py +49 -9
  46. ripperdoc/tools/notebook_edit_tool.py +57 -13
  47. ripperdoc/tools/skill_tool.py +205 -0
  48. ripperdoc/tools/task_tool.py +7 -8
  49. ripperdoc/tools/todo_tool.py +12 -12
  50. ripperdoc/tools/tool_search_tool.py +5 -6
  51. ripperdoc/utils/coerce.py +34 -0
  52. ripperdoc/utils/context_length_errors.py +252 -0
  53. ripperdoc/utils/file_watch.py +5 -4
  54. ripperdoc/utils/json_utils.py +4 -4
  55. ripperdoc/utils/log.py +3 -3
  56. ripperdoc/utils/mcp.py +36 -15
  57. ripperdoc/utils/memory.py +9 -6
  58. ripperdoc/utils/message_compaction.py +16 -11
  59. ripperdoc/utils/messages.py +73 -8
  60. ripperdoc/utils/path_ignore.py +677 -0
  61. ripperdoc/utils/permissions/__init__.py +7 -1
  62. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  63. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  64. ripperdoc/utils/prompt.py +1 -1
  65. ripperdoc/utils/safe_get_cwd.py +5 -2
  66. ripperdoc/utils/session_history.py +38 -19
  67. ripperdoc/utils/todo.py +6 -2
  68. ripperdoc/utils/token_estimation.py +4 -3
  69. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +12 -1
  70. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  71. ripperdoc-0.2.4.dist-info/RECORD +0 -99
  72. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  73. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  74. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  75. {ripperdoc-0.2.4.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,298 @@
1
+ """Tool result renderers for CLI display.
2
+
3
+ This module provides a strategy pattern implementation for rendering different
4
+ tool results in the Rich CLI interface.
5
+ """
6
+
7
+ from typing import Any, Callable, List, Optional
8
+
9
+ from rich.console import Console
10
+ from rich.markup import escape
11
+
12
+
13
+ class ToolResultRenderer:
14
+ """Base class for rendering tool results to console."""
15
+
16
+ def __init__(self, console: Console, verbose: bool = False):
17
+ self.console = console
18
+ self.verbose = verbose
19
+
20
+ def can_handle(self, _sender: str) -> bool:
21
+ """Return True if this renderer handles the given tool name."""
22
+ raise NotImplementedError
23
+
24
+ def render(self, _content: str, _tool_data: Any) -> None:
25
+ """Render the tool result to console."""
26
+ raise NotImplementedError
27
+
28
+ def _get_field(self, data: Any, key: str, default: Any = None) -> Any:
29
+ """Safely fetch a field from either an object or a dict."""
30
+ if isinstance(data, dict):
31
+ return data.get(key, default)
32
+ return getattr(data, key, default)
33
+
34
+
35
+ class TodoResultRenderer(ToolResultRenderer):
36
+ """Render Todo tool results."""
37
+
38
+ def can_handle(self, sender: str) -> bool:
39
+ return "Todo" in sender
40
+
41
+ def render(self, content: str, _tool_data: Any) -> None:
42
+ lines = content.splitlines()
43
+ if lines:
44
+ self.console.print(f" ⎿ [dim]{escape(lines[0])}[/]")
45
+ for line in lines[1:]:
46
+ self.console.print(f" {line}", markup=False)
47
+ else:
48
+ self.console.print(" ⎿ [dim]Todo update[/]")
49
+
50
+
51
+ class ReadResultRenderer(ToolResultRenderer):
52
+ """Render Read/View tool results."""
53
+
54
+ def can_handle(self, sender: str) -> bool:
55
+ return "Read" in sender or "View" in sender
56
+
57
+ def render(self, content: str, _tool_data: Any) -> None:
58
+ lines = content.split("\n")
59
+ line_count = len(lines)
60
+ self.console.print(f" ⎿ [dim]Read {line_count} lines[/]")
61
+ if self.verbose:
62
+ preview = lines[:30]
63
+ for line in preview:
64
+ self.console.print(line, markup=False)
65
+ if len(lines) > len(preview):
66
+ self.console.print(f"[dim]... ({len(lines) - len(preview)} more lines)[/]")
67
+
68
+
69
+ class EditResultRenderer(ToolResultRenderer):
70
+ """Render Write/Edit/MultiEdit tool results."""
71
+
72
+ def can_handle(self, sender: str) -> bool:
73
+ return "Write" in sender or "Edit" in sender or "MultiEdit" in sender
74
+
75
+ def render(self, _content: str, tool_data: Any) -> None:
76
+ if tool_data and (hasattr(tool_data, "file_path") or isinstance(tool_data, dict)):
77
+ file_path = self._get_field(tool_data, "file_path")
78
+ additions = self._get_field(tool_data, "additions", 0)
79
+ deletions = self._get_field(tool_data, "deletions", 0)
80
+ diff_with_line_numbers = self._get_field(tool_data, "diff_with_line_numbers", [])
81
+
82
+ if not file_path:
83
+ self.console.print(" ⎿ [dim]File updated successfully[/]")
84
+ return
85
+
86
+ self.console.print(
87
+ f" ⎿ [dim]Updated {escape(str(file_path))} with {additions} additions and {deletions} removals[/]"
88
+ )
89
+
90
+ if self.verbose:
91
+ for line in diff_with_line_numbers:
92
+ self.console.print(line, markup=False)
93
+ else:
94
+ self.console.print(" ⎿ [dim]File updated successfully[/]")
95
+
96
+
97
+ class GlobResultRenderer(ToolResultRenderer):
98
+ """Render Glob tool results."""
99
+
100
+ def can_handle(self, sender: str) -> bool:
101
+ return "Glob" in sender
102
+
103
+ def render(self, content: str, _tool_data: Any) -> None:
104
+ files = content.split("\n")
105
+ file_count = len([f for f in files if f.strip()])
106
+ self.console.print(f" ⎿ [dim]Found {file_count} files[/]")
107
+ if self.verbose:
108
+ for line in files[:30]:
109
+ if line.strip():
110
+ self.console.print(f" {line}", markup=False)
111
+ if file_count > 30:
112
+ self.console.print(f"[dim]... ({file_count - 30} more)[/]")
113
+
114
+
115
+ class GrepResultRenderer(ToolResultRenderer):
116
+ """Render Grep tool results."""
117
+
118
+ def can_handle(self, sender: str) -> bool:
119
+ return "Grep" in sender
120
+
121
+ def render(self, content: str, _tool_data: Any) -> None:
122
+ matches = content.split("\n")
123
+ match_count = len([m for m in matches if m.strip()])
124
+ self.console.print(f" ⎿ [dim]Found {match_count} matches[/]")
125
+ if self.verbose:
126
+ for line in matches[:30]:
127
+ if line.strip():
128
+ self.console.print(f" {line}", markup=False)
129
+ if match_count > 30:
130
+ self.console.print(f"[dim]... ({match_count - 30} more)[/]")
131
+
132
+
133
+ class LSResultRenderer(ToolResultRenderer):
134
+ """Render LS tool results."""
135
+
136
+ def can_handle(self, sender: str) -> bool:
137
+ return "LS" in sender
138
+
139
+ def render(self, content: str, _tool_data: Any) -> None:
140
+ tree_lines = content.splitlines()
141
+ self.console.print(f" ⎿ [dim]Directory tree ({len(tree_lines)} lines)[/]")
142
+ if self.verbose:
143
+ preview = tree_lines[:40]
144
+ for line in preview:
145
+ self.console.print(f" {line}", markup=False)
146
+ if len(tree_lines) > len(preview):
147
+ self.console.print(f"[dim]... ({len(tree_lines) - len(preview)} more)[/]")
148
+
149
+
150
+ # Type alias for bash output parser callback
151
+ BashOutputParser = Callable[[str], tuple[List[str], List[str]]]
152
+
153
+
154
+ class BashResultRenderer(ToolResultRenderer):
155
+ """Render Bash tool results."""
156
+
157
+ def __init__(
158
+ self, console: Console, verbose: bool = False, parse_fallback: Optional[BashOutputParser] = None
159
+ ):
160
+ super().__init__(console, verbose)
161
+ self._parse_fallback = parse_fallback
162
+
163
+ def can_handle(self, sender: str) -> bool:
164
+ return "Bash" in sender
165
+
166
+ def render(self, content: str, tool_data: Any) -> None:
167
+ stdout_lines: List[str] = []
168
+ stderr_lines: List[str] = []
169
+ exit_code = 0
170
+ duration_ms = 0
171
+ timeout_ms = 0
172
+
173
+ if tool_data:
174
+ exit_code = self._get_field(tool_data, "exit_code", 0)
175
+ stdout = self._get_field(tool_data, "stdout", "") or ""
176
+ stderr = self._get_field(tool_data, "stderr", "") or ""
177
+ duration_ms = self._get_field(tool_data, "duration_ms", 0) or 0
178
+ timeout_ms = self._get_field(tool_data, "timeout_ms", 0) or 0
179
+ stdout_lines = stdout.splitlines() if stdout else []
180
+ stderr_lines = stderr.splitlines() if stderr else []
181
+
182
+ if not stdout_lines and not stderr_lines and content and self._parse_fallback:
183
+ stdout_lines, stderr_lines = self._parse_fallback(content)
184
+
185
+ show_inline_stdout = (
186
+ stdout_lines and not stderr_lines and exit_code == 0 and not self.verbose
187
+ )
188
+
189
+ if show_inline_stdout:
190
+ preview = stdout_lines if self.verbose else stdout_lines[:5]
191
+ self.console.print(f" ⎿ {preview[0]}", markup=False)
192
+ for line in preview[1:]:
193
+ self.console.print(f" {line}", markup=False)
194
+ if not self.verbose and len(stdout_lines) > len(preview):
195
+ self.console.print(f"[dim]... ({len(stdout_lines) - len(preview)} more lines)[/]")
196
+ else:
197
+ self._render_detailed_output(
198
+ tool_data, exit_code, duration_ms, timeout_ms, stdout_lines, stderr_lines
199
+ )
200
+
201
+ def _render_detailed_output(
202
+ self,
203
+ tool_data: Any,
204
+ exit_code: int,
205
+ duration_ms: float,
206
+ timeout_ms: int,
207
+ stdout_lines: List[str],
208
+ stderr_lines: List[str],
209
+ ) -> None:
210
+ """Render detailed Bash output with exit code, stdout, stderr."""
211
+ if tool_data:
212
+ timing = ""
213
+ if duration_ms:
214
+ timing = f" ({duration_ms / 1000:.2f}s"
215
+ if timeout_ms:
216
+ timing += f" / timeout {timeout_ms / 1000:.0f}s"
217
+ timing += ")"
218
+ elif timeout_ms:
219
+ timing = f" (timeout {timeout_ms / 1000:.0f}s)"
220
+ self.console.print(f" ⎿ [dim]Exit code {exit_code}{timing}[/]")
221
+ else:
222
+ self.console.print(" ⎿ [dim]Command executed[/]")
223
+
224
+ # Render stdout
225
+ if stdout_lines:
226
+ preview = stdout_lines if self.verbose else stdout_lines[:5]
227
+ self.console.print("[dim]stdout:[/]")
228
+ for line in preview:
229
+ self.console.print(f" {line}", markup=False)
230
+ if not self.verbose and len(stdout_lines) > len(preview):
231
+ self.console.print(
232
+ f"[dim]... ({len(stdout_lines) - len(preview)} more stdout lines)[/]"
233
+ )
234
+ else:
235
+ self.console.print("[dim]stdout:[/]")
236
+ self.console.print(" [dim](no stdout)[/]")
237
+
238
+ # Render stderr
239
+ if stderr_lines:
240
+ preview = stderr_lines if self.verbose else stderr_lines[:5]
241
+ self.console.print("[dim]stderr:[/]")
242
+ for line in preview:
243
+ self.console.print(f" {line}", markup=False)
244
+ if not self.verbose and len(stderr_lines) > len(preview):
245
+ self.console.print(
246
+ f"[dim]... ({len(stderr_lines) - len(preview)} more stderr lines)[/]"
247
+ )
248
+ else:
249
+ self.console.print("[dim]stderr:[/]")
250
+ self.console.print(" [dim](no stderr)[/]")
251
+
252
+
253
+ class ToolResultRendererRegistry:
254
+ """Registry that selects the appropriate renderer for a tool result."""
255
+
256
+ def __init__(
257
+ self, console: Console, verbose: bool = False, parse_bash_fallback: Optional[BashOutputParser] = None
258
+ ):
259
+ self.console = console
260
+ self.verbose = verbose
261
+ self._renderers: List[ToolResultRenderer] = [
262
+ TodoResultRenderer(console, verbose),
263
+ ReadResultRenderer(console, verbose),
264
+ EditResultRenderer(console, verbose),
265
+ GlobResultRenderer(console, verbose),
266
+ GrepResultRenderer(console, verbose),
267
+ LSResultRenderer(console, verbose),
268
+ BashResultRenderer(console, verbose, parse_bash_fallback),
269
+ ]
270
+
271
+ def get_renderer(self, sender: str) -> Optional[ToolResultRenderer]:
272
+ """Get the appropriate renderer for the given tool name."""
273
+ for renderer in self._renderers:
274
+ if renderer.can_handle(sender):
275
+ return renderer
276
+ return None
277
+
278
+ def render(self, sender: str, content: str, tool_data: Any) -> bool:
279
+ """Render the tool result. Returns True if rendered, False otherwise."""
280
+ renderer = self.get_renderer(sender)
281
+ if renderer:
282
+ renderer.render(content, tool_data)
283
+ return True
284
+ return False
285
+
286
+
287
+ __all__ = [
288
+ "ToolResultRenderer",
289
+ "ToolResultRendererRegistry",
290
+ "TodoResultRenderer",
291
+ "ReadResultRenderer",
292
+ "EditResultRenderer",
293
+ "GlobResultRenderer",
294
+ "GrepResultRenderer",
295
+ "LSResultRenderer",
296
+ "BashResultRenderer",
297
+ "BashOutputParser",
298
+ ]
ripperdoc/core/agents.py CHANGED
@@ -41,7 +41,7 @@ def _safe_tool_name(factory: Any, fallback: str) -> str:
41
41
  try:
42
42
  name = getattr(factory(), "name", None)
43
43
  return str(name) if name else fallback
44
- except Exception:
44
+ except (TypeError, ValueError, RuntimeError, AttributeError):
45
45
  return fallback
46
46
 
47
47
 
@@ -189,9 +189,9 @@ PLAN_AGENT_PROMPT = (
189
189
  "End your response with:\n\n"
190
190
  "### Critical Files for Implementation\n"
191
191
  "List 3-5 files most critical for implementing this plan:\n"
192
- "- path/to/file1.ts - [Brief reason: e.g., \"Core logic to modify\"]\n"
193
- "- path/to/file2.ts - [Brief reason: e.g., \"Interfaces to implement\"]\n"
194
- "- path/to/file3.ts - [Brief reason: e.g., \"Pattern to follow\"]\n\n"
192
+ '- path/to/file1.ts - [Brief reason: e.g., "Core logic to modify"]\n'
193
+ '- path/to/file2.ts - [Brief reason: e.g., "Interfaces to implement"]\n'
194
+ '- path/to/file3.ts - [Brief reason: e.g., "Pattern to follow"]\n\n'
195
195
  "REMEMBER: You can ONLY explore and plan. You CANNOT and MUST NOT write, edit, or "
196
196
  "modify any files. You do NOT have access to file editing tools."
197
197
  )
@@ -224,7 +224,7 @@ def _built_in_agents() -> List[AgentDefinition]:
224
224
  AgentDefinition(
225
225
  agent_type="explore",
226
226
  when_to_use=(
227
- 'Fast agent specialized for exploring codebases. Use this when you need to quickly find '
227
+ "Fast agent specialized for exploring codebases. Use this when you need to quickly find "
228
228
  'files by patterns (eg. "src/components/**/*.tsx"), search code for keywords (eg. "API endpoints"), '
229
229
  'or answer questions about the codebase (eg. "how do API endpoints work?"). When calling this agent, '
230
230
  'specify the desired thoroughness level: "quick" for basic searches, "medium" for moderate exploration, '
@@ -278,8 +278,12 @@ 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 Exception as exc: # pragma: no cover - defensive
282
- logger.exception("Invalid frontmatter in agent file", extra={"error": str(exc)})
281
+ except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
282
+ logger.warning(
283
+ "Invalid frontmatter in agent file: %s: %s",
284
+ type(exc).__name__, exc,
285
+ extra={"error": str(exc)},
286
+ )
283
287
  return {"__error__": f"Invalid frontmatter: {exc}"}, body
284
288
  return frontmatter, body
285
289
  return {}, raw_text
@@ -305,8 +309,12 @@ def _parse_agent_file(
305
309
  """Parse a single agent file."""
306
310
  try:
307
311
  text = path.read_text(encoding="utf-8")
308
- except Exception as exc:
309
- logger.exception("Failed to read agent file", extra={"error": str(exc), "path": str(path)})
312
+ except (OSError, IOError, UnicodeDecodeError) as exc:
313
+ logger.warning(
314
+ "Failed to read agent file: %s: %s",
315
+ type(exc).__name__, exc,
316
+ extra={"error": str(exc), "path": str(path)},
317
+ )
310
318
  return None, f"Failed to read agent file {path}: {exc}"
311
319
 
312
320
  frontmatter, body = _split_frontmatter(text)
ripperdoc/core/config.py CHANGED
@@ -110,6 +110,9 @@ class ModelProfile(BaseModel):
110
110
  # Tool handling for OpenAI-compatible providers. "native" uses tool_calls, "text" flattens tool
111
111
  # interactions into plain text to support providers that reject tool roles.
112
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
113
116
  # Pricing (USD per 1M tokens). Leave as 0 to skip cost calculation.
114
117
  input_cost_per_million_tokens: float = 0.0
115
118
  output_cost_per_million_tokens: float = 0.0
@@ -140,6 +143,10 @@ class GlobalConfig(BaseModel):
140
143
  auto_compact_enabled: bool = True
141
144
  context_token_limit: Optional[int] = None
142
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
+
143
150
  # Onboarding
144
151
  has_completed_onboarding: bool = False
145
152
  last_onboarding_version: Optional[str] = None
@@ -151,12 +158,18 @@ class GlobalConfig(BaseModel):
151
158
  class ProjectConfig(BaseModel):
152
159
  """Project-specific configuration stored in .ripperdoc/config.json"""
153
160
 
154
- # Tool permissions
161
+ # Tool permissions (project level - checked into git)
155
162
  allowed_tools: list[str] = Field(default_factory=list)
156
163
  bash_allow_rules: list[str] = Field(default_factory=list)
157
164
  bash_deny_rules: list[str] = Field(default_factory=list)
158
165
  working_directories: list[str] = Field(default_factory=list)
159
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
+
160
173
  # Context
161
174
  context: Dict[str, str] = Field(default_factory=dict)
162
175
  context_files: list[str] = Field(default_factory=list)
@@ -177,6 +190,14 @@ class ProjectConfig(BaseModel):
177
190
  last_session_id: Optional[str] = None
178
191
 
179
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
+
180
201
  class ConfigManager:
181
202
  """Manages global and project-specific configuration."""
182
203
 
@@ -185,6 +206,7 @@ class ConfigManager:
185
206
  self.current_project_path: Optional[Path] = None
186
207
  self._global_config: Optional[GlobalConfig] = None
187
208
  self._project_config: Optional[ProjectConfig] = None
209
+ self._project_local_config: Optional[ProjectLocalConfig] = None
188
210
 
189
211
  def get_global_config(self) -> GlobalConfig:
190
212
  """Load and return global configuration."""
@@ -200,8 +222,12 @@ class ConfigManager:
200
222
  "profile_count": len(self._global_config.model_profiles),
201
223
  },
202
224
  )
203
- except Exception as e:
204
- logger.exception("Error loading global config", extra={"error": str(e)})
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
+ )
205
231
  self._global_config = GlobalConfig()
206
232
  else:
207
233
  self._global_config = GlobalConfig()
@@ -250,9 +276,10 @@ class ConfigManager:
250
276
  "allowed_tools": len(self._project_config.allowed_tools),
251
277
  },
252
278
  )
253
- except Exception as e:
254
- logger.exception(
255
- "Error loading project config",
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,
256
283
  extra={"error": str(e), "path": str(config_path)},
257
284
  )
258
285
  self._project_config = ProjectConfig()
@@ -293,6 +320,91 @@ class ConfigManager:
293
320
  },
294
321
  )
295
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
+
296
408
  def get_api_key(self, provider: ProviderType) -> Optional[str]:
297
409
  """Get API key for a provider."""
298
410
  # First check environment variables
@@ -433,3 +545,15 @@ def set_model_pointer(pointer: str, profile_name: str) -> GlobalConfig:
433
545
  def get_current_model_profile(pointer: str = "main") -> Optional[ModelProfile]:
434
546
  """Convenience wrapper to fetch the active profile for a pointer."""
435
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)
@@ -17,6 +17,7 @@ from ripperdoc.tools.file_write_tool import FileWriteTool
17
17
  from ripperdoc.tools.glob_tool import GlobTool
18
18
  from ripperdoc.tools.ls_tool import LSTool
19
19
  from ripperdoc.tools.grep_tool import GrepTool
20
+ from ripperdoc.tools.skill_tool import SkillTool
20
21
  from ripperdoc.tools.todo_tool import TodoReadTool, TodoWriteTool
21
22
  from ripperdoc.tools.ask_user_question_tool import AskUserQuestionTool
22
23
  from ripperdoc.tools.enter_plan_mode_tool import EnterPlanModeTool
@@ -48,6 +49,7 @@ def get_default_tools() -> List[Tool[Any, Any]]:
48
49
  GlobTool(),
49
50
  LSTool(),
50
51
  GrepTool(),
52
+ SkillTool(),
51
53
  TodoReadTool(),
52
54
  TodoWriteTool(),
53
55
  AskUserQuestionTool(),
@@ -66,9 +68,12 @@ def get_default_tools() -> List[Tool[Any, Any]]:
66
68
  if isinstance(tool, Tool):
67
69
  base_tools.append(tool)
68
70
  dynamic_tools.append(tool)
69
- except Exception:
71
+ except (ImportError, ModuleNotFoundError, OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc:
70
72
  # If MCP runtime is not available, continue with base tools only.
71
- logger.exception("[default_tools] Failed to load dynamic MCP tools")
73
+ logger.warning(
74
+ "[default_tools] Failed to load dynamic MCP tools: %s: %s",
75
+ type(exc).__name__, exc,
76
+ )
72
77
 
73
78
  task_tool = TaskTool(lambda: base_tools)
74
79
  all_tools = base_tools + [task_tool]
@@ -48,19 +48,19 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
48
48
  if hasattr(parsed_input, "file_path"):
49
49
  try:
50
50
  return f"{tool.name}::path::{Path(getattr(parsed_input, 'file_path')).resolve()}"
51
- except Exception:
52
- logger.exception(
51
+ except (OSError, RuntimeError) as exc:
52
+ logger.warning(
53
53
  "[permissions] Failed to resolve file_path for permission key",
54
- extra={"tool": getattr(tool, "name", None)},
54
+ extra={"tool": getattr(tool, "name", None), "error": str(exc)},
55
55
  )
56
56
  return f"{tool.name}::path::{getattr(parsed_input, 'file_path')}"
57
57
  if hasattr(parsed_input, "path"):
58
58
  try:
59
59
  return f"{tool.name}::path::{Path(getattr(parsed_input, 'path')).resolve()}"
60
- except Exception:
61
- logger.exception(
60
+ except (OSError, RuntimeError) as exc:
61
+ logger.warning(
62
62
  "[permissions] Failed to resolve path for permission key",
63
- extra={"tool": getattr(tool, "name", None)},
63
+ extra={"tool": getattr(tool, "name", None), "error": str(exc)},
64
64
  )
65
65
  return f"{tool.name}::path::{getattr(parsed_input, 'path')}"
66
66
  return tool.name
@@ -126,14 +126,15 @@ def make_permission_checker(
126
126
  try:
127
127
  if hasattr(tool, "needs_permissions") and not tool.needs_permissions(parsed_input):
128
128
  return PermissionResult(result=True)
129
- except Exception:
130
- logger.exception(
129
+ except (TypeError, AttributeError, ValueError) as exc:
130
+ # Tool implementation error - log and deny for safety
131
+ logger.warning(
131
132
  "[permissions] Tool needs_permissions check failed",
132
- extra={"tool": getattr(tool, "name", None)},
133
+ extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
133
134
  )
134
135
  return PermissionResult(
135
136
  result=False,
136
- message="Permission check failed for this tool invocation.",
137
+ message=f"Permission check failed: {type(exc).__name__}: {exc}",
137
138
  )
138
139
 
139
140
  allowed_tools = set(config.allowed_tools or [])
@@ -167,14 +168,15 @@ def make_permission_checker(
167
168
  # Allow tools to return a plain dict shaped like PermissionDecision.
168
169
  if isinstance(decision, dict) and "behavior" in decision:
169
170
  decision = PermissionDecision(**decision)
170
- except Exception:
171
- logger.exception(
171
+ except (TypeError, AttributeError, ValueError, KeyError) as exc:
172
+ # Tool implementation error - fall back to asking user
173
+ logger.warning(
172
174
  "[permissions] Tool check_permissions failed",
173
- extra={"tool": getattr(tool, "name", None)},
175
+ extra={"tool": getattr(tool, "name", None), "error": str(exc), "error_type": type(exc).__name__},
174
176
  )
175
177
  decision = PermissionDecision(
176
178
  behavior="ask",
177
- message="Error checking permissions for this tool.",
179
+ message=f"Error checking permissions: {type(exc).__name__}",
178
180
  rule_suggestions=None,
179
181
  )
180
182
 
@@ -219,6 +221,10 @@ def make_permission_checker(
219
221
  ]
220
222
 
221
223
  answer = (await _prompt_user(prompt, options=options)).strip().lower()
224
+ logger.debug(
225
+ "[permissions] User answer for permission prompt",
226
+ extra={"answer": answer, "tool": getattr(tool, "name", None)},
227
+ )
222
228
  rule_suggestions = _rule_strings(decision.rule_suggestions) or [
223
229
  permission_key(tool, parsed_input)
224
230
  ]