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.
Files changed (37) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +9 -1
  3. ripperdoc/cli/commands/agents_cmd.py +93 -53
  4. ripperdoc/cli/commands/mcp_cmd.py +3 -0
  5. ripperdoc/cli/commands/models_cmd.py +768 -283
  6. ripperdoc/cli/commands/permissions_cmd.py +107 -52
  7. ripperdoc/cli/commands/resume_cmd.py +61 -51
  8. ripperdoc/cli/commands/themes_cmd.py +31 -1
  9. ripperdoc/cli/ui/agents_tui/__init__.py +3 -0
  10. ripperdoc/cli/ui/agents_tui/textual_app.py +1138 -0
  11. ripperdoc/cli/ui/choice.py +376 -0
  12. ripperdoc/cli/ui/models_tui/__init__.py +5 -0
  13. ripperdoc/cli/ui/models_tui/textual_app.py +698 -0
  14. ripperdoc/cli/ui/panels.py +19 -4
  15. ripperdoc/cli/ui/permissions_tui/__init__.py +3 -0
  16. ripperdoc/cli/ui/permissions_tui/textual_app.py +526 -0
  17. ripperdoc/cli/ui/provider_options.py +220 -80
  18. ripperdoc/cli/ui/rich_ui.py +9 -11
  19. ripperdoc/cli/ui/tips.py +89 -0
  20. ripperdoc/cli/ui/wizard.py +98 -45
  21. ripperdoc/core/config.py +3 -0
  22. ripperdoc/core/permissions.py +25 -70
  23. ripperdoc/core/providers/anthropic.py +11 -0
  24. ripperdoc/protocol/stdio.py +3 -1
  25. ripperdoc/tools/bash_tool.py +2 -0
  26. ripperdoc/tools/file_edit_tool.py +100 -181
  27. ripperdoc/tools/file_read_tool.py +101 -25
  28. ripperdoc/tools/multi_edit_tool.py +239 -91
  29. ripperdoc/tools/notebook_edit_tool.py +11 -29
  30. ripperdoc/utils/file_editing.py +164 -0
  31. ripperdoc/utils/permissions/tool_permission_utils.py +11 -0
  32. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/METADATA +3 -2
  33. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/RECORD +37 -28
  34. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/WHEEL +0 -0
  35. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/entry_points.txt +0 -0
  36. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/licenses/LICENSE +0 -0
  37. {ripperdoc-0.3.1.dist-info → ripperdoc-0.3.2.dist-info}/top_level.txt +0 -0
@@ -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
- Ripperdoc is an AI-powered coding assistant that helps with software development tasks.
28
- You can read files, edit code, run commands, and help with various programming tasks.
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,3 @@
1
+ from .textual_app import run_permissions_tui
2
+
3
+ __all__ = ["run_permissions_tui"]
@@ -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