ripperdoc 0.3.0__py3-none-any.whl → 0.3.2__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 +1 -1
- ripperdoc/cli/cli.py +9 -1
- ripperdoc/cli/commands/agents_cmd.py +93 -53
- ripperdoc/cli/commands/mcp_cmd.py +3 -0
- ripperdoc/cli/commands/models_cmd.py +768 -283
- ripperdoc/cli/commands/permissions_cmd.py +107 -52
- ripperdoc/cli/commands/resume_cmd.py +61 -51
- ripperdoc/cli/commands/themes_cmd.py +31 -1
- ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
- ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
- ripperdoc/cli/ui/choice.py +376 -0
- ripperdoc/cli/ui/interrupt_listener.py +233 -0
- ripperdoc/cli/ui/message_display.py +7 -0
- ripperdoc/cli/ui/models_tui/__init__.py +5 -0
- ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
- ripperdoc/cli/ui/panels.py +19 -4
- ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
- ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
- ripperdoc/cli/ui/provider_options.py +220 -80
- ripperdoc/cli/ui/rich_ui.py +91 -83
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +66 -104
- ripperdoc/core/providers/anthropic.py +11 -0
- ripperdoc/protocol/stdio.py +3 -1
- ripperdoc/tools/bash_tool.py +2 -0
- ripperdoc/tools/file_edit_tool.py +100 -181
- ripperdoc/tools/file_read_tool.py +101 -25
- ripperdoc/tools/multi_edit_tool.py +239 -91
- ripperdoc/tools/notebook_edit_tool.py +11 -29
- ripperdoc/utils/file_editing.py +164 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +39 -30
- ripperdoc/cli/ui/interrupt_handler.py +0 -208
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.0.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
ripperdoc/core/permissions.py
CHANGED
|
@@ -3,11 +3,13 @@
|
|
|
3
3
|
from __future__ import annotations
|
|
4
4
|
|
|
5
5
|
import asyncio
|
|
6
|
+
import html
|
|
6
7
|
from collections import defaultdict
|
|
7
8
|
from dataclasses import dataclass
|
|
8
9
|
from pathlib import Path
|
|
9
10
|
from typing import Any, Awaitable, Callable, Optional, Set, TYPE_CHECKING, TYPE_CHECKING as TYPE_CHECKING
|
|
10
11
|
|
|
12
|
+
from ripperdoc.cli.ui.choice import prompt_choice
|
|
11
13
|
from ripperdoc.core.config import config_manager
|
|
12
14
|
from ripperdoc.core.hooks.manager import hook_manager
|
|
13
15
|
from ripperdoc.core.tool import Tool
|
|
@@ -36,35 +38,37 @@ def _format_input_preview(parsed_input: Any, tool_name: Optional[str] = None) ->
|
|
|
36
38
|
|
|
37
39
|
For Bash commands, shows full details for security review.
|
|
38
40
|
For other tools, shows a concise preview.
|
|
41
|
+
Returns HTML-formatted text with color tags.
|
|
39
42
|
"""
|
|
40
43
|
# For Bash tool, show full command details for security review
|
|
41
44
|
if tool_name == "Bash" and hasattr(parsed_input, "command"):
|
|
42
|
-
|
|
45
|
+
command = html.escape(getattr(parsed_input, "command"))
|
|
46
|
+
lines = [f"<label>Command:</label> <value>{command}</value>"]
|
|
43
47
|
|
|
44
48
|
# Add other relevant parameters
|
|
45
49
|
if hasattr(parsed_input, "timeout") and parsed_input.timeout:
|
|
46
|
-
lines.append(f"Timeout
|
|
50
|
+
lines.append(f"<label>Timeout:</label> <value>{parsed_input.timeout}ms</value>")
|
|
47
51
|
if hasattr(parsed_input, "sandbox"):
|
|
48
|
-
lines.append(f"Sandbox
|
|
52
|
+
lines.append(f"<label>Sandbox:</label> <value>{parsed_input.sandbox}</value>")
|
|
49
53
|
if hasattr(parsed_input, "run_in_background"):
|
|
50
|
-
lines.append(f"Background
|
|
54
|
+
lines.append(f"<label>Background:</label> <value>{parsed_input.run_in_background}</value>")
|
|
51
55
|
if hasattr(parsed_input, "shell_executable") and parsed_input.shell_executable:
|
|
52
|
-
lines.append(f"Shell
|
|
56
|
+
lines.append(f"<label>Shell:</label> <value>{html.escape(parsed_input.shell_executable)}</value>")
|
|
53
57
|
|
|
54
58
|
return "\n ".join(lines)
|
|
55
59
|
|
|
56
60
|
# For other tools with commands, show concise preview
|
|
57
61
|
if hasattr(parsed_input, "command"):
|
|
58
|
-
return f"command
|
|
62
|
+
return f"<label>command:</label> <value>'{html.escape(getattr(parsed_input, 'command'))}'</value>"
|
|
59
63
|
if hasattr(parsed_input, "file_path"):
|
|
60
|
-
return f"file
|
|
64
|
+
return f"<label>file:</label> <value>'{html.escape(getattr(parsed_input, 'file_path'))}'</value>"
|
|
61
65
|
if hasattr(parsed_input, "path"):
|
|
62
|
-
return f"path
|
|
66
|
+
return f"<label>path:</label> <value>'{html.escape(getattr(parsed_input, 'path'))}'</value>"
|
|
63
67
|
|
|
64
68
|
preview = str(parsed_input)
|
|
65
69
|
if len(preview) > 140:
|
|
66
|
-
|
|
67
|
-
return preview
|
|
70
|
+
preview = preview[:137] + "..."
|
|
71
|
+
return f"<value>{html.escape(preview)}</value>"
|
|
68
72
|
|
|
69
73
|
|
|
70
74
|
def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
|
|
@@ -92,55 +96,6 @@ def permission_key(tool: Tool[Any, Any], parsed_input: Any) -> str:
|
|
|
92
96
|
return tool.name
|
|
93
97
|
|
|
94
98
|
|
|
95
|
-
def _render_options_prompt(prompt: str, options: list[tuple[str, str]]) -> str:
|
|
96
|
-
"""Render a simple numbered prompt (fallback for non-Rich environments)."""
|
|
97
|
-
border = "─" * 120
|
|
98
|
-
lines = [border, prompt, ""]
|
|
99
|
-
for idx, (_, label) in enumerate(options, start=1):
|
|
100
|
-
prefix = "❯" if idx == 1 else " "
|
|
101
|
-
lines.append(f"{prefix} {idx}. {label}")
|
|
102
|
-
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
103
|
-
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
104
|
-
lines.append(f"Choice ({numeric_choices} or {shortcut_choices}): ")
|
|
105
|
-
return "\n".join(lines)
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
def _render_options_prompt_rich(
|
|
109
|
-
console: "Console",
|
|
110
|
-
prompt: str,
|
|
111
|
-
options: list[tuple[str, str]]
|
|
112
|
-
) -> None:
|
|
113
|
-
"""Render permission dialog using Rich Panel for better visual consistency."""
|
|
114
|
-
from rich.panel import Panel
|
|
115
|
-
from rich.text import Text
|
|
116
|
-
|
|
117
|
-
# Build option lines with markup
|
|
118
|
-
option_lines = []
|
|
119
|
-
for idx, (_, label) in enumerate(options, start=1):
|
|
120
|
-
prefix = "[cyan]❯[/cyan]" if idx == 1 else " "
|
|
121
|
-
option_lines.append(f"{prefix} {idx}. {label}")
|
|
122
|
-
|
|
123
|
-
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
124
|
-
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
125
|
-
|
|
126
|
-
# Build the prompt content as a markup string
|
|
127
|
-
markup_content = f"{prompt}\n\n" + "\n".join(option_lines) + "\n"
|
|
128
|
-
markup_content += f"Choice ([cyan]{numeric_choices}[/cyan] or [cyan]{shortcut_choices}[/cyan]): "
|
|
129
|
-
|
|
130
|
-
# Parse markup to create a Text object
|
|
131
|
-
content = Text.from_markup(markup_content)
|
|
132
|
-
|
|
133
|
-
# Render the panel
|
|
134
|
-
panel = Panel(
|
|
135
|
-
content,
|
|
136
|
-
title=Text.from_markup("[yellow]Permission Required[/yellow]"),
|
|
137
|
-
title_align="left",
|
|
138
|
-
border_style="yellow",
|
|
139
|
-
padding=(0, 1),
|
|
140
|
-
)
|
|
141
|
-
console.print(panel)
|
|
142
|
-
|
|
143
|
-
|
|
144
99
|
def _rule_strings(rule_suggestions: Optional[Any]) -> list[str]:
|
|
145
100
|
"""Normalize rule suggestions to simple strings."""
|
|
146
101
|
if not rule_suggestions:
|
|
@@ -158,8 +113,8 @@ def make_permission_checker(
|
|
|
158
113
|
project_path: Path,
|
|
159
114
|
yolo_mode: bool,
|
|
160
115
|
prompt_fn: Optional[Callable[[str], str]] = None,
|
|
161
|
-
console: Optional["Console"] = None,
|
|
162
|
-
prompt_session: Optional["PromptSession"] = None,
|
|
116
|
+
console: Optional["Console"] = None, # noqa: ARG001 (kept for backward compatibility)
|
|
117
|
+
prompt_session: Optional["PromptSession"] = None, # noqa: ARG001 (kept for backward compatibility)
|
|
163
118
|
) -> Callable[[Tool[Any, Any], Any], Awaitable[PermissionResult]]:
|
|
164
119
|
"""Create a permission checking function for the current project.
|
|
165
120
|
|
|
@@ -167,12 +122,13 @@ def make_permission_checker(
|
|
|
167
122
|
project_path: Path to the project directory
|
|
168
123
|
yolo_mode: If True, all tool calls are allowed without prompting
|
|
169
124
|
prompt_fn: Optional function to use for prompting (defaults to input())
|
|
170
|
-
console:
|
|
171
|
-
prompt_session:
|
|
125
|
+
console: (Deprecated) No longer used, kept for backward compatibility
|
|
126
|
+
prompt_session: (Deprecated) No longer used, kept for backward compatibility
|
|
172
127
|
|
|
173
128
|
In yolo mode, all tool calls are allowed without prompting.
|
|
174
129
|
"""
|
|
175
130
|
|
|
131
|
+
_ = console, prompt_session # Mark as intentionally unused
|
|
176
132
|
project_path = project_path.resolve()
|
|
177
133
|
config_manager.get_project_config(project_path)
|
|
178
134
|
|
|
@@ -180,40 +136,37 @@ def make_permission_checker(
|
|
|
180
136
|
session_tool_rules: dict[str, Set[str]] = defaultdict(set)
|
|
181
137
|
|
|
182
138
|
async def _prompt_user(prompt: str, options: list[tuple[str, str]]) -> str:
|
|
183
|
-
"""Prompt the user with proper interrupt handling.
|
|
184
|
-
# Build the prompt message
|
|
185
|
-
if console is not None:
|
|
186
|
-
# Use Rich Panel for the dialog
|
|
187
|
-
_render_options_prompt_rich(console, prompt, options)
|
|
188
|
-
# Build simple prompt for the input line
|
|
189
|
-
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
190
|
-
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
191
|
-
input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
|
|
192
|
-
else:
|
|
193
|
-
# Use plain text rendering
|
|
194
|
-
rendered = _render_options_prompt(prompt, options)
|
|
195
|
-
input_prompt = rendered
|
|
196
|
-
|
|
197
|
-
# Try to use PromptSession if available (better interrupt handling)
|
|
198
|
-
if prompt_session is not None:
|
|
199
|
-
try:
|
|
200
|
-
# PromptSession.prompt() can handle Ctrl+C gracefully
|
|
201
|
-
return await prompt_session.prompt_async(input_prompt)
|
|
202
|
-
except KeyboardInterrupt:
|
|
203
|
-
logger.debug("[permissions] KeyboardInterrupt in prompt_session")
|
|
204
|
-
return "n"
|
|
205
|
-
except EOFError:
|
|
206
|
-
logger.debug("[permissions] EOFError in prompt_session")
|
|
207
|
-
return "n"
|
|
139
|
+
"""Prompt the user with proper interrupt handling using unified choice component.
|
|
208
140
|
|
|
209
|
-
|
|
141
|
+
Args:
|
|
142
|
+
prompt: The prompt text to display (supports HTML formatting).
|
|
143
|
+
options: List of (value, label) tuples for choices.
|
|
144
|
+
"""
|
|
210
145
|
loop = asyncio.get_running_loop()
|
|
211
|
-
responder = prompt_fn or input
|
|
212
146
|
|
|
213
147
|
def _ask() -> str:
|
|
214
148
|
try:
|
|
215
|
-
|
|
216
|
-
|
|
149
|
+
# If a custom prompt_fn is provided (e.g., for tests), use it directly
|
|
150
|
+
responder = prompt_fn or None
|
|
151
|
+
if responder is not None:
|
|
152
|
+
# Build a simple text prompt for the prompt_fn
|
|
153
|
+
numeric_choices = "/".join(str(i) for i in range(1, len(options) + 1))
|
|
154
|
+
shortcut_choices = "/".join(opt[0] for opt in options)
|
|
155
|
+
input_prompt = f"Choice ({numeric_choices} or {shortcut_choices}): "
|
|
156
|
+
return responder(input_prompt)
|
|
157
|
+
|
|
158
|
+
# Use the unified choice component
|
|
159
|
+
return prompt_choice(
|
|
160
|
+
message=prompt,
|
|
161
|
+
options=options,
|
|
162
|
+
allow_esc=True,
|
|
163
|
+
esc_value="n", # ESC means no
|
|
164
|
+
)
|
|
165
|
+
except KeyboardInterrupt:
|
|
166
|
+
logger.debug("[permissions] KeyboardInterrupt in choice")
|
|
167
|
+
return "n"
|
|
168
|
+
except EOFError:
|
|
169
|
+
logger.debug("[permissions] EOFError in choice")
|
|
217
170
|
return "n"
|
|
218
171
|
|
|
219
172
|
return await loop.run_in_executor(None, _ask)
|
|
@@ -264,6 +217,13 @@ def make_permission_checker(
|
|
|
264
217
|
| set(local_config.local_deny_rules or [])
|
|
265
218
|
)
|
|
266
219
|
}
|
|
220
|
+
ask_rules = {
|
|
221
|
+
"Bash": (
|
|
222
|
+
set(config.bash_ask_rules or [])
|
|
223
|
+
| set(global_config.user_ask_rules or [])
|
|
224
|
+
| set(local_config.local_ask_rules or [])
|
|
225
|
+
)
|
|
226
|
+
}
|
|
267
227
|
allowed_working_dirs = {
|
|
268
228
|
str(project_path.resolve()),
|
|
269
229
|
*[str(Path(p).resolve()) for p in config.working_directories or []],
|
|
@@ -281,6 +241,7 @@ def make_permission_checker(
|
|
|
281
241
|
{
|
|
282
242
|
"allowed_rules": allow_rules.get(tool.name, set()),
|
|
283
243
|
"denied_rules": deny_rules.get(tool.name, set()),
|
|
244
|
+
"ask_rules": ask_rules.get(tool.name, set()),
|
|
284
245
|
"allowed_working_directories": allowed_working_dirs,
|
|
285
246
|
},
|
|
286
247
|
)
|
|
@@ -315,7 +276,7 @@ def make_permission_checker(
|
|
|
315
276
|
|
|
316
277
|
# If tool doesn't normally require permission (e.g., read-only Bash),
|
|
317
278
|
# enforce deny rules but otherwise skip prompting.
|
|
318
|
-
if not needs_permission:
|
|
279
|
+
if not needs_permission and decision.behavior != "ask":
|
|
319
280
|
if decision.behavior == "deny":
|
|
320
281
|
return PermissionResult(
|
|
321
282
|
result=False,
|
|
@@ -387,20 +348,21 @@ def make_permission_checker(
|
|
|
387
348
|
)
|
|
388
349
|
|
|
389
350
|
input_preview = _format_input_preview(parsed_input, tool_name=tool.name)
|
|
390
|
-
|
|
391
|
-
|
|
392
|
-
|
|
393
|
-
|
|
394
|
-
|
|
351
|
+
# Use inline styles for prompt_toolkit HTML formatting
|
|
352
|
+
# The style names must match keys in the _permission_style() dict
|
|
353
|
+
prompt_html = f"""<title>{html.escape(tool.name)}</title>
|
|
354
|
+
|
|
355
|
+
<description>{input_preview}</description>"""
|
|
395
356
|
if decision.message:
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
357
|
+
# Use warning style for warning messages
|
|
358
|
+
prompt_html += f"\n <warning>{html.escape(decision.message)}</warning>"
|
|
359
|
+
prompt_html += "\n <question>Do you want to proceed?</question>"
|
|
360
|
+
prompt = prompt_html
|
|
399
361
|
|
|
400
362
|
options = [
|
|
401
|
-
("y", "Yes"),
|
|
402
|
-
("s", "Yes, for this session"),
|
|
403
|
-
("n", "No"),
|
|
363
|
+
("y", "<yes-option>Yes</yes-option>"),
|
|
364
|
+
("s", "<yes-option>Yes, for this session</yes-option>"),
|
|
365
|
+
("n", "<no-option>No</no-option>"),
|
|
404
366
|
]
|
|
405
367
|
|
|
406
368
|
answer = (await _prompt_user(prompt, options=options)).strip().lower()
|
|
@@ -689,6 +689,17 @@ class AnthropicClient(ProviderClient):
|
|
|
689
689
|
if usage:
|
|
690
690
|
# Update with final usage - output_tokens comes here
|
|
691
691
|
usage_tokens["output_tokens"] = getattr(usage, "output_tokens", 0)
|
|
692
|
+
# Some APIs (like zhipu) may also include input_tokens here
|
|
693
|
+
input_tokens = getattr(usage, "input_tokens", None)
|
|
694
|
+
if input_tokens is not None:
|
|
695
|
+
usage_tokens["input_tokens"] = input_tokens
|
|
696
|
+
# Also check for cache tokens
|
|
697
|
+
cache_read = getattr(usage, "cache_read_input_tokens", None)
|
|
698
|
+
if cache_read is not None:
|
|
699
|
+
usage_tokens["cache_read_input_tokens"] = cache_read
|
|
700
|
+
cache_creation = getattr(usage, "cache_creation_input_tokens", None)
|
|
701
|
+
if cache_creation is not None:
|
|
702
|
+
usage_tokens["cache_creation_input_tokens"] = cache_creation
|
|
692
703
|
|
|
693
704
|
elif event_type == "message_stop":
|
|
694
705
|
# Message complete
|
ripperdoc/protocol/stdio.py
CHANGED
|
@@ -23,7 +23,7 @@ from typing import Any, AsyncGenerator, Callable, TypeVar
|
|
|
23
23
|
import click
|
|
24
24
|
|
|
25
25
|
from ripperdoc.core.config import get_project_config, get_effective_model_profile
|
|
26
|
-
from ripperdoc.core.default_tools import get_default_tools
|
|
26
|
+
from ripperdoc.core.default_tools import filter_tools_by_names, get_default_tools
|
|
27
27
|
from ripperdoc.core.query import query, QueryContext
|
|
28
28
|
from ripperdoc.core.query_utils import resolve_model_profile
|
|
29
29
|
from ripperdoc.core.system_prompt import build_system_prompt
|
|
@@ -431,6 +431,8 @@ class StdioProtocolHandler:
|
|
|
431
431
|
dynamic_tools = await load_dynamic_mcp_tools_async(self._project_path)
|
|
432
432
|
if dynamic_tools:
|
|
433
433
|
tools = merge_tools_with_dynamic(tools, dynamic_tools)
|
|
434
|
+
if allowed_tools is not None:
|
|
435
|
+
tools = filter_tools_by_names(tools, allowed_tools)
|
|
434
436
|
self._query_context.tools = tools
|
|
435
437
|
|
|
436
438
|
mcp_instructions = format_mcp_instructions(servers)
|
ripperdoc/tools/bash_tool.py
CHANGED
|
@@ -316,6 +316,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
316
316
|
|
|
317
317
|
allow_rules = permission_context.get("allowed_rules") or set()
|
|
318
318
|
deny_rules = permission_context.get("denied_rules") or set()
|
|
319
|
+
ask_rules = permission_context.get("ask_rules") or set()
|
|
319
320
|
allowed_dirs = permission_context.get("allowed_working_directories") or {safe_get_cwd()}
|
|
320
321
|
|
|
321
322
|
# Check for sensitive directory access with read-only commands (cd, find).
|
|
@@ -340,6 +341,7 @@ build projects, run tests, and interact with the file system."""
|
|
|
340
341
|
input_data,
|
|
341
342
|
allow_rules,
|
|
342
343
|
deny_rules,
|
|
344
|
+
ask_rules,
|
|
343
345
|
allowed_dirs,
|
|
344
346
|
# danger_detector uses default: validate_shell_command(cmd).behavior != "passthrough"
|
|
345
347
|
# read_only_detector uses default: _is_command_read_only
|