ripperdoc 0.2.3__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 (76) 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 +35 -15
  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 +523 -396
  17. ripperdoc/cli/ui/tool_renderers.py +298 -0
  18. ripperdoc/core/agents.py +172 -4
  19. ripperdoc/core/config.py +130 -6
  20. ripperdoc/core/default_tools.py +13 -2
  21. ripperdoc/core/permissions.py +20 -14
  22. ripperdoc/core/providers/__init__.py +31 -15
  23. ripperdoc/core/providers/anthropic.py +122 -8
  24. ripperdoc/core/providers/base.py +93 -15
  25. ripperdoc/core/providers/gemini.py +539 -96
  26. ripperdoc/core/providers/openai.py +371 -26
  27. ripperdoc/core/query.py +301 -62
  28. ripperdoc/core/query_utils.py +51 -7
  29. ripperdoc/core/skills.py +295 -0
  30. ripperdoc/core/system_prompt.py +79 -67
  31. ripperdoc/core/tool.py +15 -6
  32. ripperdoc/sdk/client.py +14 -1
  33. ripperdoc/tools/ask_user_question_tool.py +431 -0
  34. ripperdoc/tools/background_shell.py +82 -26
  35. ripperdoc/tools/bash_tool.py +356 -209
  36. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  37. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  38. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  39. ripperdoc/tools/file_edit_tool.py +53 -10
  40. ripperdoc/tools/file_read_tool.py +17 -7
  41. ripperdoc/tools/file_write_tool.py +49 -13
  42. ripperdoc/tools/glob_tool.py +10 -9
  43. ripperdoc/tools/grep_tool.py +182 -51
  44. ripperdoc/tools/ls_tool.py +6 -6
  45. ripperdoc/tools/mcp_tools.py +172 -413
  46. ripperdoc/tools/multi_edit_tool.py +49 -9
  47. ripperdoc/tools/notebook_edit_tool.py +57 -13
  48. ripperdoc/tools/skill_tool.py +205 -0
  49. ripperdoc/tools/task_tool.py +91 -9
  50. ripperdoc/tools/todo_tool.py +12 -12
  51. ripperdoc/tools/tool_search_tool.py +5 -6
  52. ripperdoc/utils/coerce.py +34 -0
  53. ripperdoc/utils/context_length_errors.py +252 -0
  54. ripperdoc/utils/file_watch.py +5 -4
  55. ripperdoc/utils/json_utils.py +4 -4
  56. ripperdoc/utils/log.py +3 -3
  57. ripperdoc/utils/mcp.py +82 -22
  58. ripperdoc/utils/memory.py +9 -6
  59. ripperdoc/utils/message_compaction.py +19 -16
  60. ripperdoc/utils/messages.py +73 -8
  61. ripperdoc/utils/path_ignore.py +677 -0
  62. ripperdoc/utils/permissions/__init__.py +7 -1
  63. ripperdoc/utils/permissions/path_validation_utils.py +5 -3
  64. ripperdoc/utils/permissions/shell_command_validation.py +496 -18
  65. ripperdoc/utils/prompt.py +1 -1
  66. ripperdoc/utils/safe_get_cwd.py +5 -2
  67. ripperdoc/utils/session_history.py +38 -19
  68. ripperdoc/utils/todo.py +6 -2
  69. ripperdoc/utils/token_estimation.py +34 -0
  70. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/METADATA +14 -1
  71. ripperdoc-0.2.5.dist-info/RECORD +107 -0
  72. ripperdoc-0.2.3.dist-info/RECORD +0 -95
  73. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/WHEEL +0 -0
  74. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/entry_points.txt +0 -0
  75. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/licenses/LICENSE +0 -0
  76. {ripperdoc-0.2.3.dist-info → ripperdoc-0.2.5.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,302 @@
1
+ """Slash command to manage permission rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, List, Literal
7
+
8
+ from rich.markup import escape
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from ripperdoc.core.config import (
13
+ config_manager,
14
+ get_global_config,
15
+ get_project_config,
16
+ get_project_local_config,
17
+ save_global_config,
18
+ save_project_config,
19
+ save_project_local_config,
20
+ )
21
+
22
+ from .base import SlashCommand
23
+
24
+
25
+ ScopeType = Literal["user", "project", "local"]
26
+
27
+
28
+ def _shorten_path(path: Path, project_path: Path) -> str:
29
+ """Return a short, user-friendly path."""
30
+ try:
31
+ return str(path.resolve().relative_to(project_path.resolve()))
32
+ except (ValueError, OSError):
33
+ pass
34
+
35
+ home = Path.home()
36
+ try:
37
+ rel_home = path.resolve().relative_to(home)
38
+ return f"~/{rel_home}"
39
+ except (ValueError, OSError):
40
+ return str(path)
41
+
42
+
43
+ def _get_scope_info(scope: ScopeType, project_path: Path) -> tuple[str, str]:
44
+ """Return (heading, config_path) for a given scope."""
45
+ if scope == "user":
46
+ return "User (global)", str(Path.home() / ".ripperdoc.json")
47
+ elif scope == "project":
48
+ return "Project (shared)", str(project_path / ".ripperdoc" / "config.json")
49
+ else: # local
50
+ return "Local (private)", str(project_path / ".ripperdoc" / "config.local.json")
51
+
52
+
53
+ def _get_rules_for_scope(
54
+ scope: ScopeType, project_path: Path
55
+ ) -> tuple[List[str], List[str]]:
56
+ """Return (allow_rules, deny_rules) for a given scope."""
57
+ if scope == "user":
58
+ config = get_global_config()
59
+ return list(config.user_allow_rules), list(config.user_deny_rules)
60
+ elif scope == "project":
61
+ config = get_project_config(project_path)
62
+ return list(config.bash_allow_rules), list(config.bash_deny_rules)
63
+ else: # local
64
+ config = get_project_local_config(project_path)
65
+ return list(config.local_allow_rules), list(config.local_deny_rules)
66
+
67
+
68
+ def _add_rule(
69
+ scope: ScopeType,
70
+ rule_type: Literal["allow", "deny"],
71
+ rule: str,
72
+ project_path: Path,
73
+ ) -> bool:
74
+ """Add a rule to the specified scope. Returns True if added, False if already exists."""
75
+ if scope == "user":
76
+ config = get_global_config()
77
+ rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_rules
78
+ if rule in rules:
79
+ return False
80
+ rules.append(rule)
81
+ save_global_config(config)
82
+ elif scope == "project":
83
+ config = get_project_config(project_path)
84
+ rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_rules
85
+ if rule in rules:
86
+ return False
87
+ rules.append(rule)
88
+ save_project_config(config, project_path)
89
+ else: # local
90
+ config = get_project_local_config(project_path)
91
+ rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_rules
92
+ if rule in rules:
93
+ return False
94
+ rules.append(rule)
95
+ save_project_local_config(config, project_path)
96
+ return True
97
+
98
+
99
+ def _remove_rule(
100
+ scope: ScopeType,
101
+ rule_type: Literal["allow", "deny"],
102
+ rule: str,
103
+ project_path: Path,
104
+ ) -> bool:
105
+ """Remove a rule from the specified scope. Returns True if removed, False if not found."""
106
+ if scope == "user":
107
+ config = get_global_config()
108
+ rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_rules
109
+ if rule not in rules:
110
+ return False
111
+ rules.remove(rule)
112
+ save_global_config(config)
113
+ elif scope == "project":
114
+ config = get_project_config(project_path)
115
+ rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_rules
116
+ if rule not in rules:
117
+ return False
118
+ rules.remove(rule)
119
+ save_project_config(config, project_path)
120
+ else: # local
121
+ config = get_project_local_config(project_path)
122
+ rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_rules
123
+ if rule not in rules:
124
+ return False
125
+ rules.remove(rule)
126
+ save_project_local_config(config, project_path)
127
+ return True
128
+
129
+
130
+ def _render_all_rules(console: Any, project_path: Path) -> None:
131
+ """Display all permission rules from all scopes."""
132
+ table = Table(title="Permission Rules", show_header=True, header_style="bold cyan")
133
+ table.add_column("Scope", style="bold")
134
+ table.add_column("Type", style="dim")
135
+ table.add_column("Rule")
136
+
137
+ has_rules = False
138
+
139
+ for scope in ("user", "project", "local"):
140
+ allow_rules, deny_rules = _get_rules_for_scope(scope, project_path) # type: ignore
141
+
142
+ for rule in allow_rules:
143
+ table.add_row(scope, "[green]allow[/green]", escape(rule))
144
+ has_rules = True
145
+
146
+ for rule in deny_rules:
147
+ table.add_row(scope, "[red]deny[/red]", escape(rule))
148
+ has_rules = True
149
+
150
+ if has_rules:
151
+ console.print(table)
152
+ else:
153
+ console.print("[yellow]No permission rules configured yet.[/yellow]")
154
+
155
+ console.print()
156
+ console.print("[dim]Scopes (in priority order):[/dim]")
157
+ console.print("[dim] - user: Global rules (~/.ripperdoc.json)[/dim]")
158
+ console.print("[dim] - project: Shared project rules (.ripperdoc/config.json)[/dim]")
159
+ console.print("[dim] - local: Private project rules (.ripperdoc/config.local.json)[/dim]")
160
+
161
+
162
+ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> None:
163
+ """Display rules for a specific scope."""
164
+ heading, config_path = _get_scope_info(scope, project_path)
165
+ allow_rules, deny_rules = _get_rules_for_scope(scope, project_path)
166
+
167
+ table = Table(title=f"{heading} Permission Rules", show_header=True, header_style="bold cyan")
168
+ table.add_column("Type", style="dim")
169
+ table.add_column("Rule")
170
+
171
+ has_rules = False
172
+ for rule in allow_rules:
173
+ table.add_row("[green]allow[/green]", escape(rule))
174
+ has_rules = True
175
+
176
+ for rule in deny_rules:
177
+ table.add_row("[red]deny[/red]", escape(rule))
178
+ has_rules = True
179
+
180
+ if has_rules:
181
+ console.print(table)
182
+ else:
183
+ console.print(f"[yellow]No {scope} rules configured.[/yellow]")
184
+
185
+ console.print(f"[dim]Config file: {escape(config_path)}[/dim]")
186
+
187
+
188
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
189
+ project_path = getattr(ui, "project_path", Path.cwd())
190
+ args = trimmed_arg.strip().split()
191
+
192
+ # No args: show all rules
193
+ if not args:
194
+ _render_all_rules(ui.console, project_path)
195
+ ui.console.print()
196
+ ui.console.print("[dim]Usage:[/dim]")
197
+ ui.console.print("[dim] /permissions - Show all rules[/dim]")
198
+ ui.console.print("[dim] /permissions <scope> - Show rules for scope[/dim]")
199
+ ui.console.print("[dim] /permissions add <scope> <type> <rule> - Add a rule[/dim]")
200
+ ui.console.print("[dim] /permissions remove <scope> <type> <rule> - Remove a rule[/dim]")
201
+ ui.console.print("[dim]Scopes: user, project, local[/dim]")
202
+ ui.console.print("[dim]Types: allow, deny[/dim]")
203
+ return True
204
+
205
+ # Parse command
206
+ action = args[0].lower()
207
+ scope_aliases = {
208
+ "user": "user",
209
+ "global": "user",
210
+ "project": "project",
211
+ "workspace": "project",
212
+ "local": "local",
213
+ "private": "local",
214
+ }
215
+
216
+ # Single scope display
217
+ if action in scope_aliases:
218
+ scope = scope_aliases[action]
219
+ _render_scope_rules(ui.console, scope, project_path) # type: ignore
220
+ return True
221
+
222
+ # Add rule
223
+ if action == "add":
224
+ if len(args) < 4:
225
+ ui.console.print("[red]Usage: /permissions add <scope> <type> <rule>[/red]")
226
+ ui.console.print("[dim]Example: /permissions add local allow npm test[/dim]")
227
+ return True
228
+
229
+ scope_arg = args[1].lower()
230
+ if scope_arg not in scope_aliases:
231
+ ui.console.print(f"[red]Unknown scope: {escape(scope_arg)}[/red]")
232
+ ui.console.print("[dim]Available scopes: user, project, local[/dim]")
233
+ return True
234
+ scope: ScopeType = scope_aliases[scope_arg] # type: ignore
235
+
236
+ rule_type_arg = args[2].lower()
237
+ if rule_type_arg not in ("allow", "deny"):
238
+ ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
239
+ ui.console.print("[dim]Available types: allow, deny[/dim]")
240
+ return True
241
+ rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore
242
+
243
+ rule = " ".join(args[3:])
244
+ if _add_rule(scope, rule_type, rule, project_path):
245
+ ui.console.print(
246
+ Panel(
247
+ f"Added [{'green' if rule_type == 'allow' else 'red'}]{rule_type}[/] rule to {scope}:\n{escape(rule)}",
248
+ title="/permissions",
249
+ )
250
+ )
251
+ else:
252
+ ui.console.print(f"[yellow]Rule already exists in {scope}.[/yellow]")
253
+ return True
254
+
255
+ # Remove rule
256
+ if action in ("remove", "rm", "delete", "del"):
257
+ if len(args) < 4:
258
+ ui.console.print("[red]Usage: /permissions remove <scope> <type> <rule>[/red]")
259
+ ui.console.print("[dim]Example: /permissions remove local allow npm test[/dim]")
260
+ return True
261
+
262
+ scope_arg = args[1].lower()
263
+ if scope_arg not in scope_aliases:
264
+ ui.console.print(f"[red]Unknown scope: {escape(scope_arg)}[/red]")
265
+ ui.console.print("[dim]Available scopes: user, project, local[/dim]")
266
+ return True
267
+ scope = scope_aliases[scope_arg] # type: ignore
268
+
269
+ rule_type_arg = args[2].lower()
270
+ if rule_type_arg not in ("allow", "deny"):
271
+ ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
272
+ ui.console.print("[dim]Available types: allow, deny[/dim]")
273
+ return True
274
+ rule_type = rule_type_arg # type: ignore
275
+
276
+ rule = " ".join(args[3:])
277
+ if _remove_rule(scope, rule_type, rule, project_path):
278
+ ui.console.print(
279
+ Panel(
280
+ f"Removed [{'green' if rule_type == 'allow' else 'red'}]{rule_type}[/] rule from {scope}:\n{escape(rule)}",
281
+ title="/permissions",
282
+ )
283
+ )
284
+ else:
285
+ ui.console.print(f"[yellow]Rule not found in {scope}.[/yellow]")
286
+ return True
287
+
288
+ # Unknown command
289
+ ui.console.print(f"[red]Unknown action: {escape(action)}[/red]")
290
+ ui.console.print("[dim]Available actions: add, remove, or a scope name[/dim]")
291
+ return True
292
+
293
+
294
+ command = SlashCommand(
295
+ name="permissions",
296
+ description="Manage permission rules for tools",
297
+ handler=_handle,
298
+ aliases=(),
299
+ )
300
+
301
+
302
+ __all__ = ["command"]
@@ -61,8 +61,7 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
61
61
  idx = int(choice_text)
62
62
  if idx < 0 or idx >= len(sessions):
63
63
  ui.console.print(
64
- f"[red]Invalid session index {escape(str(idx))}. "
65
- f"Choose 0-{len(sessions) - 1}.[/red]"
64
+ f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
66
65
  )
67
66
  return None
68
67
  return sessions[idx]
@@ -105,10 +105,11 @@ def _list_tasks(ui: Any) -> bool:
105
105
  for task_id in sorted(task_ids):
106
106
  try:
107
107
  status = get_background_status(task_id, consume=False)
108
- except Exception as exc:
108
+ except (KeyError, ValueError, RuntimeError, OSError) as exc:
109
109
  table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
110
- logger.exception(
111
- "[tasks_cmd] Failed to read background task status",
110
+ logger.warning(
111
+ "[tasks_cmd] Failed to read background task status: %s: %s",
112
+ type(exc).__name__, exc,
112
113
  extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
113
114
  )
114
115
  continue
@@ -143,10 +144,11 @@ def _kill_task(ui: Any, task_id: str) -> bool:
143
144
  except KeyError:
144
145
  console.print(f"[red]No task found with id '{escape(task_id)}'.[/red]")
145
146
  return True
146
- except Exception as exc:
147
+ except (ValueError, RuntimeError, OSError) as exc:
147
148
  console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
148
- logger.exception(
149
- "[tasks_cmd] Failed to read task before kill",
149
+ logger.warning(
150
+ "[tasks_cmd] Failed to read task before kill: %s: %s",
151
+ type(exc).__name__, exc,
150
152
  extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
151
153
  )
152
154
  return True
@@ -157,12 +159,20 @@ def _kill_task(ui: Any, task_id: str) -> bool:
157
159
  )
158
160
  return True
159
161
 
162
+ runner = getattr(ui, "run_async", None)
163
+
160
164
  try:
161
- killed = asyncio.run(kill_background_task(task_id))
162
- except Exception as exc:
165
+ if callable(runner):
166
+ killed = runner(kill_background_task(task_id))
167
+ else:
168
+ killed = asyncio.run(kill_background_task(task_id))
169
+ except (OSError, RuntimeError, asyncio.CancelledError) as exc:
170
+ if isinstance(exc, asyncio.CancelledError):
171
+ raise
163
172
  console.print(f"[red]Error stopping task {escape(task_id)}: {escape(str(exc))}[/red]")
164
- logger.exception(
165
- "[tasks_cmd] Error stopping background task",
173
+ logger.warning(
174
+ "[tasks_cmd] Error stopping background task: %s: %s",
175
+ type(exc).__name__, exc,
166
176
  extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
167
177
  )
168
178
  return True
@@ -183,10 +193,11 @@ def _show_task(ui: Any, task_id: str) -> bool:
183
193
  except KeyError:
184
194
  console.print(f"[red]No task found with id '{escape(task_id)}'.[/red]")
185
195
  return True
186
- except Exception as exc:
196
+ except (ValueError, RuntimeError, OSError) as exc:
187
197
  console.print(f"[red]Failed to read task '{escape(task_id)}': {escape(str(exc))}[/red]")
188
- logger.exception(
189
- "[tasks_cmd] Failed to read task for detail view",
198
+ logger.warning(
199
+ "[tasks_cmd] Failed to read task for detail view: %s: %s",
200
+ type(exc).__name__, exc,
190
201
  extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
191
202
  )
192
203
  return True