ripperdoc 0.3.1__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/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 +9 -11
- ripperdoc/cli/ui/tips.py +89 -0
- ripperdoc/cli/ui/wizard.py +98 -45
- ripperdoc/core/config.py +3 -0
- ripperdoc/core/permissions.py +25 -70
- 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.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
ripperdoc/cli/ui/panels.py
CHANGED
|
@@ -18,16 +18,23 @@ from ripperdoc.core.theme import theme_color
|
|
|
18
18
|
|
|
19
19
|
def create_welcome_panel() -> Panel:
|
|
20
20
|
"""Create a welcome panel for the CLI startup."""
|
|
21
|
+
import os
|
|
22
|
+
|
|
21
23
|
primary = theme_color("primary")
|
|
22
24
|
muted = theme_color("text_secondary")
|
|
23
25
|
|
|
26
|
+
profile = get_profile_for_pointer("main")
|
|
27
|
+
model_name = profile.model if profile else "Not configured"
|
|
28
|
+
protocol = profile.provider.value if profile else "unknown"
|
|
29
|
+
cwd = os.getcwd()
|
|
30
|
+
|
|
31
|
+
secondary = theme_color("secondary")
|
|
24
32
|
welcome_content = f"""
|
|
25
33
|
[bold {primary}]Welcome to Ripperdoc![/bold {primary}]
|
|
26
34
|
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
[{muted}]Type your questions below. Press Ctrl+C twice to exit.[/{muted}]
|
|
35
|
+
[{muted}]model: {model_name} • [{secondary}]Ready[/{secondary}]
|
|
36
|
+
protocol: {protocol}
|
|
37
|
+
directory: {cwd}[/{muted}]
|
|
31
38
|
"""
|
|
32
39
|
|
|
33
40
|
return Panel(
|
|
@@ -36,20 +43,28 @@ You can read files, edit code, run commands, and help with various programming t
|
|
|
36
43
|
border_style=theme_color("border"),
|
|
37
44
|
box=box.ROUNDED,
|
|
38
45
|
padding=(1, 2),
|
|
46
|
+
expand=False,
|
|
39
47
|
)
|
|
40
48
|
|
|
41
49
|
|
|
42
50
|
def create_status_bar() -> Text:
|
|
43
51
|
"""Create a status bar with current model information."""
|
|
52
|
+
import os
|
|
53
|
+
|
|
44
54
|
profile = get_profile_for_pointer("main")
|
|
45
55
|
model_name = profile.model if profile else "Not configured"
|
|
46
56
|
|
|
57
|
+
# Get current working directory
|
|
58
|
+
cwd = os.getcwd()
|
|
59
|
+
|
|
47
60
|
status_text = Text()
|
|
48
61
|
status_text.append("Ripperdoc", style=f"bold {theme_color('primary')}")
|
|
49
62
|
status_text.append(" • ")
|
|
50
63
|
status_text.append(model_name, style=theme_color("text_secondary"))
|
|
51
64
|
status_text.append(" • ")
|
|
52
65
|
status_text.append("Ready", style=theme_color("secondary"))
|
|
66
|
+
status_text.append(" • ")
|
|
67
|
+
status_text.append(cwd, style=theme_color("text_secondary"))
|
|
53
68
|
|
|
54
69
|
return status_text
|
|
55
70
|
|
|
@@ -0,0 +1,526 @@
|
|
|
1
|
+
"""Textual app for managing permission rules."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import re
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Callable, Optional
|
|
9
|
+
|
|
10
|
+
from rich.text import Text
|
|
11
|
+
|
|
12
|
+
from textual import events
|
|
13
|
+
from textual.app import App, ComposeResult
|
|
14
|
+
from textual.containers import Container, Horizontal, VerticalScroll
|
|
15
|
+
from textual.screen import ModalScreen
|
|
16
|
+
from textual.widgets import Button, Footer, Header, Input, OptionList, Static
|
|
17
|
+
from textual.widgets.option_list import Option
|
|
18
|
+
|
|
19
|
+
from ripperdoc.core.config import (
|
|
20
|
+
GlobalConfig,
|
|
21
|
+
ProjectConfig,
|
|
22
|
+
ProjectLocalConfig,
|
|
23
|
+
get_global_config,
|
|
24
|
+
get_project_config,
|
|
25
|
+
get_project_local_config,
|
|
26
|
+
save_global_config,
|
|
27
|
+
save_project_config,
|
|
28
|
+
save_project_local_config,
|
|
29
|
+
)
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
ScopeType = str
|
|
33
|
+
RuleType = str
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@dataclass
|
|
37
|
+
class RuleSelection:
|
|
38
|
+
rule: str
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
class ConfirmScreen(ModalScreen[bool]):
|
|
42
|
+
"""Simple confirmation dialog."""
|
|
43
|
+
|
|
44
|
+
def __init__(self, message: str) -> None:
|
|
45
|
+
super().__init__()
|
|
46
|
+
self._message = message
|
|
47
|
+
|
|
48
|
+
def compose(self) -> ComposeResult:
|
|
49
|
+
with Container(id="confirm_dialog"):
|
|
50
|
+
yield Static(self._message, id="confirm_message")
|
|
51
|
+
with Horizontal(id="confirm_buttons"):
|
|
52
|
+
yield Button("Yes", id="confirm_yes", variant="primary")
|
|
53
|
+
yield Button("No", id="confirm_no")
|
|
54
|
+
|
|
55
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
56
|
+
if event.button.id == "confirm_yes":
|
|
57
|
+
self.dismiss(True)
|
|
58
|
+
else:
|
|
59
|
+
self.dismiss(False)
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class AddRuleScreen(ModalScreen[Optional[str]]):
|
|
63
|
+
"""Modal screen for adding permission rules."""
|
|
64
|
+
|
|
65
|
+
BINDINGS = [("escape", "cancel", "Cancel")]
|
|
66
|
+
|
|
67
|
+
def __init__(self, rule_type: str) -> None:
|
|
68
|
+
super().__init__()
|
|
69
|
+
self._rule_type = rule_type
|
|
70
|
+
|
|
71
|
+
def compose(self) -> ComposeResult:
|
|
72
|
+
title = f"Add {self._rule_type} permission rule"
|
|
73
|
+
with Container(id="add_dialog"):
|
|
74
|
+
yield Static(title, id="add_title")
|
|
75
|
+
yield Static(
|
|
76
|
+
"Permission rules are a tool name, optionally followed by a specifier in parentheses.",
|
|
77
|
+
id="add_hint",
|
|
78
|
+
)
|
|
79
|
+
yield Static("e.g., WebFetch or Bash(ls:*)", id="add_example")
|
|
80
|
+
yield Static("", id="add_error")
|
|
81
|
+
with VerticalScroll(id="add_fields"):
|
|
82
|
+
yield Input(placeholder="Enter permission rule...", id="rule_input")
|
|
83
|
+
with Horizontal(id="add_buttons"):
|
|
84
|
+
yield Button("Add", id="add_submit", variant="primary")
|
|
85
|
+
yield Button("Cancel", id="add_cancel")
|
|
86
|
+
|
|
87
|
+
def on_mount(self) -> None:
|
|
88
|
+
self.query_one("#rule_input", Input).focus()
|
|
89
|
+
|
|
90
|
+
def action_cancel(self) -> None:
|
|
91
|
+
self.dismiss(None)
|
|
92
|
+
|
|
93
|
+
def on_button_pressed(self, event: Button.Pressed) -> None:
|
|
94
|
+
if event.button.id == "add_cancel":
|
|
95
|
+
self.dismiss(None)
|
|
96
|
+
return
|
|
97
|
+
if event.button.id != "add_submit":
|
|
98
|
+
return
|
|
99
|
+
raw = (self.query_one("#rule_input", Input).value or "").strip()
|
|
100
|
+
if not raw:
|
|
101
|
+
self._set_error("Rule cannot be empty.")
|
|
102
|
+
return
|
|
103
|
+
self.dismiss(raw)
|
|
104
|
+
|
|
105
|
+
def on_input_submitted(self, event: Input.Submitted) -> None:
|
|
106
|
+
if event.input.id != "rule_input":
|
|
107
|
+
return
|
|
108
|
+
raw = (event.value or "").strip()
|
|
109
|
+
if not raw:
|
|
110
|
+
self._set_error("Rule cannot be empty.")
|
|
111
|
+
return
|
|
112
|
+
self.dismiss(raw)
|
|
113
|
+
|
|
114
|
+
def _set_error(self, message: str) -> None:
|
|
115
|
+
self.query_one("#add_error", Static).update(message)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class PermissionsApp(App[None]):
|
|
119
|
+
CSS = """
|
|
120
|
+
#status_bar {
|
|
121
|
+
height: auto;
|
|
122
|
+
padding: 0 1;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
#description {
|
|
126
|
+
color: $text-muted;
|
|
127
|
+
padding: 0 1 0 1;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
#status_message {
|
|
131
|
+
color: $text-muted;
|
|
132
|
+
padding: 0 1 0 1;
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
#search_input {
|
|
136
|
+
margin: 0 1 0 1;
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
#rules_list {
|
|
140
|
+
margin: 0 1 1 1;
|
|
141
|
+
height: 1fr;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
#hint_bar {
|
|
145
|
+
color: $text-muted;
|
|
146
|
+
padding: 0 1 1 1;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
#confirm_dialog, #add_dialog {
|
|
150
|
+
width: 84;
|
|
151
|
+
max-height: 90%;
|
|
152
|
+
background: $panel;
|
|
153
|
+
border: round $accent;
|
|
154
|
+
padding: 1 2;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
#add_title {
|
|
158
|
+
text-style: bold;
|
|
159
|
+
padding: 0 0 1 0;
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
#add_hint, #add_example {
|
|
163
|
+
color: $text-muted;
|
|
164
|
+
padding: 0 0 1 0;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
#add_error {
|
|
168
|
+
color: $error;
|
|
169
|
+
padding: 0 0 1 0;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
#add_buttons, #confirm_buttons {
|
|
173
|
+
align-horizontal: right;
|
|
174
|
+
padding-top: 1;
|
|
175
|
+
height: auto;
|
|
176
|
+
}
|
|
177
|
+
"""
|
|
178
|
+
|
|
179
|
+
BINDINGS = [
|
|
180
|
+
("left", "prev_type", "Prev type"),
|
|
181
|
+
("right", "next_type", "Next type"),
|
|
182
|
+
("tab", "next_type", "Next type"),
|
|
183
|
+
("shift+tab", "prev_type", "Prev type"),
|
|
184
|
+
("shift+left", "prev_scope", "Prev scope"),
|
|
185
|
+
("shift+right", "next_scope", "Next scope"),
|
|
186
|
+
("q", "quit", "Quit"),
|
|
187
|
+
]
|
|
188
|
+
|
|
189
|
+
_TYPE_ORDER = ("allow", "ask", "deny")
|
|
190
|
+
_SCOPE_ORDER = ("project", "user", "local")
|
|
191
|
+
|
|
192
|
+
def __init__(self, project_path: Path) -> None:
|
|
193
|
+
super().__init__()
|
|
194
|
+
self._project_path = project_path
|
|
195
|
+
self._rule_type: RuleType = "allow"
|
|
196
|
+
self._scope: ScopeType = "project"
|
|
197
|
+
self._search_query: str = ""
|
|
198
|
+
self._rule_map: dict[str, str] = {}
|
|
199
|
+
|
|
200
|
+
def compose(self) -> ComposeResult:
|
|
201
|
+
yield Header(show_clock=False)
|
|
202
|
+
yield Static("", id="status_bar")
|
|
203
|
+
yield Static("", id="description")
|
|
204
|
+
yield Static("", id="status_message")
|
|
205
|
+
yield Input(placeholder="Search...", id="search_input")
|
|
206
|
+
yield OptionList(id="rules_list")
|
|
207
|
+
yield Static(
|
|
208
|
+
"Press ↑↓ to navigate · Enter to select · Type to search · Esc to clear search",
|
|
209
|
+
id="hint_bar",
|
|
210
|
+
)
|
|
211
|
+
yield Footer()
|
|
212
|
+
|
|
213
|
+
def on_mount(self) -> None:
|
|
214
|
+
search_input = self.query_one("#search_input", Input)
|
|
215
|
+
search_input.disabled = True
|
|
216
|
+
self._refresh_view()
|
|
217
|
+
self.query_one("#rules_list", OptionList).focus()
|
|
218
|
+
|
|
219
|
+
def on_key(self, event: events.Key) -> None:
|
|
220
|
+
if len(self.screen_stack) > 1:
|
|
221
|
+
return
|
|
222
|
+
if event.key == "tab":
|
|
223
|
+
self.action_next_type()
|
|
224
|
+
event.stop()
|
|
225
|
+
return
|
|
226
|
+
if event.key == "shift+tab":
|
|
227
|
+
self.action_prev_type()
|
|
228
|
+
event.stop()
|
|
229
|
+
return
|
|
230
|
+
if event.key == "escape":
|
|
231
|
+
if self._search_query:
|
|
232
|
+
self._update_search("")
|
|
233
|
+
event.stop()
|
|
234
|
+
return
|
|
235
|
+
if event.key == "backspace":
|
|
236
|
+
if self._search_query:
|
|
237
|
+
self._update_search(self._search_query[:-1])
|
|
238
|
+
event.stop()
|
|
239
|
+
return
|
|
240
|
+
if event.character and event.character.isprintable():
|
|
241
|
+
if event.character not in ("\t", "\n"):
|
|
242
|
+
self._update_search(self._search_query + event.character)
|
|
243
|
+
event.stop()
|
|
244
|
+
|
|
245
|
+
def action_next_type(self) -> None:
|
|
246
|
+
idx = self._TYPE_ORDER.index(self._rule_type)
|
|
247
|
+
self._rule_type = self._TYPE_ORDER[(idx + 1) % len(self._TYPE_ORDER)]
|
|
248
|
+
self._refresh_view()
|
|
249
|
+
|
|
250
|
+
def action_prev_type(self) -> None:
|
|
251
|
+
idx = self._TYPE_ORDER.index(self._rule_type)
|
|
252
|
+
self._rule_type = self._TYPE_ORDER[(idx - 1) % len(self._TYPE_ORDER)]
|
|
253
|
+
self._refresh_view()
|
|
254
|
+
|
|
255
|
+
def action_next_scope(self) -> None:
|
|
256
|
+
idx = self._SCOPE_ORDER.index(self._scope)
|
|
257
|
+
self._scope = self._SCOPE_ORDER[(idx + 1) % len(self._SCOPE_ORDER)]
|
|
258
|
+
self._refresh_view()
|
|
259
|
+
|
|
260
|
+
def action_prev_scope(self) -> None:
|
|
261
|
+
idx = self._SCOPE_ORDER.index(self._scope)
|
|
262
|
+
self._scope = self._SCOPE_ORDER[(idx - 1) % len(self._SCOPE_ORDER)]
|
|
263
|
+
self._refresh_view()
|
|
264
|
+
|
|
265
|
+
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
266
|
+
option_id = event.option.id or ""
|
|
267
|
+
if option_id == "__add__":
|
|
268
|
+
self.push_screen(AddRuleScreen(self._rule_type), self._handle_add_result)
|
|
269
|
+
return
|
|
270
|
+
if option_id.startswith("rule:"):
|
|
271
|
+
rule = self._rule_map.get(option_id)
|
|
272
|
+
if not rule:
|
|
273
|
+
return
|
|
274
|
+
self.push_screen(
|
|
275
|
+
ConfirmScreen(f"Remove rule '{self._format_rule_display(rule)}'?"),
|
|
276
|
+
lambda confirmed: self._handle_remove_result(confirmed, rule),
|
|
277
|
+
)
|
|
278
|
+
|
|
279
|
+
def _handle_add_result(self, rule_input: Optional[str]) -> None:
|
|
280
|
+
if not rule_input:
|
|
281
|
+
return
|
|
282
|
+
rule = self._normalize_rule_input(rule_input)
|
|
283
|
+
if not rule:
|
|
284
|
+
self._set_status("Rule cannot be empty.")
|
|
285
|
+
return
|
|
286
|
+
if self._add_rule(self._scope, self._rule_type, rule):
|
|
287
|
+
self._set_status(f"Added {self._rule_type} rule.")
|
|
288
|
+
else:
|
|
289
|
+
self._set_status("Rule already exists.")
|
|
290
|
+
self._refresh_view()
|
|
291
|
+
|
|
292
|
+
def _handle_remove_result(self, confirmed: bool, rule: str) -> None:
|
|
293
|
+
if not confirmed:
|
|
294
|
+
return
|
|
295
|
+
if self._remove_rule(self._scope, self._rule_type, rule):
|
|
296
|
+
self._set_status("Rule removed.")
|
|
297
|
+
else:
|
|
298
|
+
self._set_status("Rule not found.")
|
|
299
|
+
self._refresh_view()
|
|
300
|
+
|
|
301
|
+
def _refresh_view(self) -> None:
|
|
302
|
+
self._update_status_bar()
|
|
303
|
+
self._update_description()
|
|
304
|
+
self._update_search_input()
|
|
305
|
+
self._refresh_list()
|
|
306
|
+
|
|
307
|
+
def _update_status_bar(self) -> None:
|
|
308
|
+
status = self.query_one("#status_bar", Static)
|
|
309
|
+
type_parts = []
|
|
310
|
+
for item in self._TYPE_ORDER:
|
|
311
|
+
label = item.capitalize() if item != "ask" else "Ask"
|
|
312
|
+
if item == self._rule_type:
|
|
313
|
+
label = f"[reverse bold]{label}[/reverse bold]"
|
|
314
|
+
type_parts.append(label)
|
|
315
|
+
status.update(
|
|
316
|
+
f"Permissions: {' '.join(type_parts)} (←/→ or tab to cycle)"
|
|
317
|
+
)
|
|
318
|
+
|
|
319
|
+
def _update_description(self) -> None:
|
|
320
|
+
description = self.query_one("#description", Static)
|
|
321
|
+
if self._rule_type == "allow":
|
|
322
|
+
text = "Ripperdoc won't ask before using allowed tools."
|
|
323
|
+
elif self._rule_type == "deny":
|
|
324
|
+
text = "Ripperdoc will block matching tools."
|
|
325
|
+
else:
|
|
326
|
+
text = "Ripperdoc will always ask for matching tools."
|
|
327
|
+
description.update(text)
|
|
328
|
+
|
|
329
|
+
def _update_search_input(self) -> None:
|
|
330
|
+
search_input = self.query_one("#search_input", Input)
|
|
331
|
+
search_input.value = self._search_query
|
|
332
|
+
|
|
333
|
+
def _refresh_list(self) -> None:
|
|
334
|
+
option_list = self.query_one("#rules_list", OptionList)
|
|
335
|
+
option_list.clear_options()
|
|
336
|
+
self._rule_map = {}
|
|
337
|
+
|
|
338
|
+
allow_rules, deny_rules, ask_rules = self._get_rules_for_scope(self._scope)
|
|
339
|
+
if self._rule_type == "allow":
|
|
340
|
+
rules = allow_rules
|
|
341
|
+
elif self._rule_type == "deny":
|
|
342
|
+
rules = deny_rules
|
|
343
|
+
else:
|
|
344
|
+
rules = ask_rules
|
|
345
|
+
filtered = self._filter_rules(rules, self._search_query)
|
|
346
|
+
|
|
347
|
+
option_list.add_option(Option("1. Add a new rule...", id="__add__"))
|
|
348
|
+
for idx, rule in enumerate(filtered, start=2):
|
|
349
|
+
display = self._format_rule_display(rule)
|
|
350
|
+
option_id = f"rule:{idx}"
|
|
351
|
+
self._rule_map[option_id] = rule
|
|
352
|
+
option_list.add_option(Option(f"{idx}. {display}", id=option_id))
|
|
353
|
+
|
|
354
|
+
if option_list.option_count:
|
|
355
|
+
option_list.highlighted = 0
|
|
356
|
+
|
|
357
|
+
def _update_search(self, query: str) -> None:
|
|
358
|
+
self._search_query = query
|
|
359
|
+
self._refresh_view()
|
|
360
|
+
|
|
361
|
+
@staticmethod
|
|
362
|
+
def _filter_rules(rules: list[str], query: str) -> list[str]:
|
|
363
|
+
if not query:
|
|
364
|
+
return list(rules)
|
|
365
|
+
needle = query.lower()
|
|
366
|
+
filtered = []
|
|
367
|
+
for rule in rules:
|
|
368
|
+
display = PermissionsApp._format_rule_display(rule).lower()
|
|
369
|
+
if needle in rule.lower() or needle in display:
|
|
370
|
+
filtered.append(rule)
|
|
371
|
+
return filtered
|
|
372
|
+
|
|
373
|
+
@staticmethod
|
|
374
|
+
def _scope_label(scope: ScopeType) -> str:
|
|
375
|
+
if scope == "project":
|
|
376
|
+
return "Workspace"
|
|
377
|
+
if scope == "user":
|
|
378
|
+
return "User"
|
|
379
|
+
return "Local"
|
|
380
|
+
|
|
381
|
+
@staticmethod
|
|
382
|
+
def _parse_tool_rule(rule: str) -> tuple[Optional[str], Optional[str]]:
|
|
383
|
+
match = re.match(r"\s*([A-Za-z0-9_-]+)\s*\((.*)\)\s*$", rule)
|
|
384
|
+
if not match:
|
|
385
|
+
return None, None
|
|
386
|
+
return match.group(1), match.group(2)
|
|
387
|
+
|
|
388
|
+
@staticmethod
|
|
389
|
+
def _normalize_rule_input(rule: str) -> str:
|
|
390
|
+
rule = rule.strip()
|
|
391
|
+
tool, inner = PermissionsApp._parse_tool_rule(rule)
|
|
392
|
+
if tool and inner is not None:
|
|
393
|
+
if tool.lower() == "bash":
|
|
394
|
+
return inner.strip()
|
|
395
|
+
return rule
|
|
396
|
+
return rule
|
|
397
|
+
|
|
398
|
+
@staticmethod
|
|
399
|
+
def _format_rule_display(rule: str) -> str:
|
|
400
|
+
tool, _ = PermissionsApp._parse_tool_rule(rule)
|
|
401
|
+
if tool:
|
|
402
|
+
return rule
|
|
403
|
+
return f"Bash({rule})"
|
|
404
|
+
|
|
405
|
+
def _get_rules_for_scope(self, scope: ScopeType) -> tuple[list[str], list[str], list[str]]:
|
|
406
|
+
if scope == "user":
|
|
407
|
+
user_config: GlobalConfig = get_global_config()
|
|
408
|
+
return (
|
|
409
|
+
list(user_config.user_allow_rules),
|
|
410
|
+
list(user_config.user_deny_rules),
|
|
411
|
+
list(user_config.user_ask_rules),
|
|
412
|
+
)
|
|
413
|
+
if scope == "project":
|
|
414
|
+
project_config: ProjectConfig = get_project_config(self._project_path)
|
|
415
|
+
return (
|
|
416
|
+
list(project_config.bash_allow_rules),
|
|
417
|
+
list(project_config.bash_deny_rules),
|
|
418
|
+
list(project_config.bash_ask_rules),
|
|
419
|
+
)
|
|
420
|
+
local_config: ProjectLocalConfig = get_project_local_config(self._project_path)
|
|
421
|
+
return (
|
|
422
|
+
list(local_config.local_allow_rules),
|
|
423
|
+
list(local_config.local_deny_rules),
|
|
424
|
+
list(local_config.local_ask_rules),
|
|
425
|
+
)
|
|
426
|
+
|
|
427
|
+
def _add_rule(self, scope: ScopeType, rule_type: RuleType, rule: str) -> bool:
|
|
428
|
+
if scope == "user":
|
|
429
|
+
user_config: GlobalConfig = get_global_config()
|
|
430
|
+
if rule_type == "allow":
|
|
431
|
+
rules = user_config.user_allow_rules
|
|
432
|
+
elif rule_type == "deny":
|
|
433
|
+
rules = user_config.user_deny_rules
|
|
434
|
+
else:
|
|
435
|
+
rules = user_config.user_ask_rules
|
|
436
|
+
if rule in rules:
|
|
437
|
+
return False
|
|
438
|
+
rules.append(rule)
|
|
439
|
+
save_global_config(user_config)
|
|
440
|
+
return True
|
|
441
|
+
if scope == "project":
|
|
442
|
+
project_config: ProjectConfig = get_project_config(self._project_path)
|
|
443
|
+
if rule_type == "allow":
|
|
444
|
+
rules = project_config.bash_allow_rules
|
|
445
|
+
elif rule_type == "deny":
|
|
446
|
+
rules = project_config.bash_deny_rules
|
|
447
|
+
else:
|
|
448
|
+
rules = project_config.bash_ask_rules
|
|
449
|
+
if rule in rules:
|
|
450
|
+
return False
|
|
451
|
+
rules.append(rule)
|
|
452
|
+
save_project_config(project_config, self._project_path)
|
|
453
|
+
return True
|
|
454
|
+
local_config: ProjectLocalConfig = get_project_local_config(self._project_path)
|
|
455
|
+
if rule_type == "allow":
|
|
456
|
+
rules = local_config.local_allow_rules
|
|
457
|
+
elif rule_type == "deny":
|
|
458
|
+
rules = local_config.local_deny_rules
|
|
459
|
+
else:
|
|
460
|
+
rules = local_config.local_ask_rules
|
|
461
|
+
if rule in rules:
|
|
462
|
+
return False
|
|
463
|
+
rules.append(rule)
|
|
464
|
+
save_project_local_config(local_config, self._project_path)
|
|
465
|
+
return True
|
|
466
|
+
|
|
467
|
+
def _remove_rule(self, scope: ScopeType, rule_type: RuleType, rule: str) -> bool:
|
|
468
|
+
if scope == "user":
|
|
469
|
+
user_config: GlobalConfig = get_global_config()
|
|
470
|
+
if rule_type == "allow":
|
|
471
|
+
rules = user_config.user_allow_rules
|
|
472
|
+
elif rule_type == "deny":
|
|
473
|
+
rules = user_config.user_deny_rules
|
|
474
|
+
else:
|
|
475
|
+
rules = user_config.user_ask_rules
|
|
476
|
+
if rule not in rules:
|
|
477
|
+
return False
|
|
478
|
+
rules.remove(rule)
|
|
479
|
+
save_global_config(user_config)
|
|
480
|
+
return True
|
|
481
|
+
if scope == "project":
|
|
482
|
+
project_config: ProjectConfig = get_project_config(self._project_path)
|
|
483
|
+
if rule_type == "allow":
|
|
484
|
+
rules = project_config.bash_allow_rules
|
|
485
|
+
elif rule_type == "deny":
|
|
486
|
+
rules = project_config.bash_deny_rules
|
|
487
|
+
else:
|
|
488
|
+
rules = project_config.bash_ask_rules
|
|
489
|
+
if rule not in rules:
|
|
490
|
+
return False
|
|
491
|
+
rules.remove(rule)
|
|
492
|
+
save_project_config(project_config, self._project_path)
|
|
493
|
+
return True
|
|
494
|
+
local_config: ProjectLocalConfig = get_project_local_config(self._project_path)
|
|
495
|
+
if rule_type == "allow":
|
|
496
|
+
rules = local_config.local_allow_rules
|
|
497
|
+
elif rule_type == "deny":
|
|
498
|
+
rules = local_config.local_deny_rules
|
|
499
|
+
else:
|
|
500
|
+
rules = local_config.local_ask_rules
|
|
501
|
+
if rule not in rules:
|
|
502
|
+
return False
|
|
503
|
+
rules.remove(rule)
|
|
504
|
+
save_project_local_config(local_config, self._project_path)
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
def _set_status(self, message: str) -> None:
|
|
508
|
+
status = self.query_one("#status_message", Static)
|
|
509
|
+
scope_label = self._scope_label(self._scope)
|
|
510
|
+
if scope_label != "Workspace" and message:
|
|
511
|
+
status.update(f"{message} Scope: {scope_label}")
|
|
512
|
+
elif scope_label != "Workspace":
|
|
513
|
+
status.update(f"Scope: {scope_label}")
|
|
514
|
+
else:
|
|
515
|
+
status.update(message)
|
|
516
|
+
|
|
517
|
+
|
|
518
|
+
def run_permissions_tui(
|
|
519
|
+
project_path: Path, on_exit: Optional[Callable[[], Any]] = None
|
|
520
|
+
) -> bool:
|
|
521
|
+
"""Run the Textual permissions TUI."""
|
|
522
|
+
app = PermissionsApp(project_path)
|
|
523
|
+
app.run()
|
|
524
|
+
if on_exit:
|
|
525
|
+
on_exit()
|
|
526
|
+
return True
|