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
@@ -2,6 +2,7 @@
2
2
 
3
3
  from __future__ import annotations
4
4
 
5
+ import sys
5
6
  from pathlib import Path
6
7
  from typing import Any, Dict, List, Literal
7
8
 
@@ -52,53 +53,72 @@ def _get_scope_info(scope: ScopeType, project_path: Path) -> tuple[str, str]:
52
53
  return "Local (private)", str(project_path / ".ripperdoc" / "config.local.json")
53
54
 
54
55
 
55
- def _get_rules_for_scope(scope: ScopeType, project_path: Path) -> tuple[List[str], List[str]]:
56
- """Return (allow_rules, deny_rules) for a given scope."""
56
+ def _get_rules_for_scope(
57
+ scope: ScopeType, project_path: Path
58
+ ) -> tuple[List[str], List[str], List[str]]:
59
+ """Return (allow_rules, deny_rules, ask_rules) for a given scope."""
57
60
  if scope == "user":
58
61
  user_config: GlobalConfig = get_global_config()
59
- return list(user_config.user_allow_rules), list(user_config.user_deny_rules)
62
+ return (
63
+ list(user_config.user_allow_rules),
64
+ list(user_config.user_deny_rules),
65
+ list(user_config.user_ask_rules),
66
+ )
60
67
  elif scope == "project":
61
68
  project_config: ProjectConfig = get_project_config(project_path)
62
- return list(project_config.bash_allow_rules), list(project_config.bash_deny_rules)
69
+ return (
70
+ list(project_config.bash_allow_rules),
71
+ list(project_config.bash_deny_rules),
72
+ list(project_config.bash_ask_rules),
73
+ )
63
74
  else: # local
64
75
  local_config: ProjectLocalConfig = get_project_local_config(project_path)
65
- return list(local_config.local_allow_rules), list(local_config.local_deny_rules)
76
+ return (
77
+ list(local_config.local_allow_rules),
78
+ list(local_config.local_deny_rules),
79
+ list(local_config.local_ask_rules),
80
+ )
66
81
 
67
82
 
68
83
  def _add_rule(
69
84
  scope: ScopeType,
70
- rule_type: Literal["allow", "deny"],
85
+ rule_type: Literal["allow", "deny", "ask"],
71
86
  rule: str,
72
87
  project_path: Path,
73
88
  ) -> bool:
74
89
  """Add a rule to the specified scope. Returns True if added, False if already exists."""
75
90
  if scope == "user":
76
91
  user_config: GlobalConfig = get_global_config()
77
- rules = (
78
- user_config.user_allow_rules if rule_type == "allow" else user_config.user_deny_rules
79
- )
92
+ if rule_type == "allow":
93
+ rules = user_config.user_allow_rules
94
+ elif rule_type == "deny":
95
+ rules = user_config.user_deny_rules
96
+ else:
97
+ rules = user_config.user_ask_rules
80
98
  if rule in rules:
81
99
  return False
82
100
  rules.append(rule)
83
101
  save_global_config(user_config)
84
102
  elif scope == "project":
85
103
  project_config: ProjectConfig = get_project_config(project_path)
86
- rules = (
87
- project_config.bash_allow_rules
88
- if rule_type == "allow"
89
- else project_config.bash_deny_rules
90
- )
104
+ if rule_type == "allow":
105
+ rules = project_config.bash_allow_rules
106
+ elif rule_type == "deny":
107
+ rules = project_config.bash_deny_rules
108
+ else:
109
+ rules = project_config.bash_ask_rules
91
110
  if rule in rules:
92
111
  return False
93
112
  rules.append(rule)
94
113
  save_project_config(project_config, project_path)
95
114
  else: # local
96
115
  local_config: ProjectLocalConfig = get_project_local_config(project_path)
97
- rules = (
98
- local_config.local_allow_rules
99
- if rule_type == "allow"
100
- else local_config.local_deny_rules
101
- )
116
+ if rule_type == "allow":
117
+ rules = local_config.local_allow_rules
118
+ elif rule_type == "deny":
119
+ rules = local_config.local_deny_rules
120
+ else:
121
+ rules = local_config.local_ask_rules
102
122
  if rule in rules:
103
123
  return False
104
124
  rules.append(rule)
@@ -108,38 +128,43 @@ def _add_rule(
108
128
 
109
129
  def _remove_rule(
110
130
  scope: ScopeType,
111
- rule_type: Literal["allow", "deny"],
131
+ rule_type: Literal["allow", "deny", "ask"],
112
132
  rule: str,
113
133
  project_path: Path,
114
134
  ) -> bool:
115
135
  """Remove a rule from the specified scope. Returns True if removed, False if not found."""
116
136
  if scope == "user":
117
137
  user_config: GlobalConfig = get_global_config()
118
- rules = (
119
- user_config.user_allow_rules if rule_type == "allow" else user_config.user_deny_rules
120
- )
138
+ if rule_type == "allow":
139
+ rules = user_config.user_allow_rules
140
+ elif rule_type == "deny":
141
+ rules = user_config.user_deny_rules
142
+ else:
143
+ rules = user_config.user_ask_rules
121
144
  if rule not in rules:
122
145
  return False
123
146
  rules.remove(rule)
124
147
  save_global_config(user_config)
125
148
  elif scope == "project":
126
149
  project_config: ProjectConfig = get_project_config(project_path)
127
- rules = (
128
- project_config.bash_allow_rules
129
- if rule_type == "allow"
130
- else project_config.bash_deny_rules
131
- )
150
+ if rule_type == "allow":
151
+ rules = project_config.bash_allow_rules
152
+ elif rule_type == "deny":
153
+ rules = project_config.bash_deny_rules
154
+ else:
155
+ rules = project_config.bash_ask_rules
132
156
  if rule not in rules:
133
157
  return False
134
158
  rules.remove(rule)
135
159
  save_project_config(project_config, project_path)
136
160
  else: # local
137
161
  local_config: ProjectLocalConfig = get_project_local_config(project_path)
138
- rules = (
139
- local_config.local_allow_rules
140
- if rule_type == "allow"
141
- else local_config.local_deny_rules
142
- )
162
+ if rule_type == "allow":
163
+ rules = local_config.local_allow_rules
164
+ elif rule_type == "deny":
165
+ rules = local_config.local_deny_rules
166
+ else:
167
+ rules = local_config.local_ask_rules
143
168
  if rule not in rules:
144
169
  return False
145
170
  rules.remove(rule)
@@ -157,12 +182,16 @@ def _render_all_rules(console: Any, project_path: Path) -> None:
157
182
  has_rules = False
158
183
 
159
184
  for scope in ("user", "project", "local"):
160
- allow_rules, deny_rules = _get_rules_for_scope(scope, project_path) # type: ignore
185
+ allow_rules, deny_rules, ask_rules = _get_rules_for_scope(scope, project_path) # type: ignore
161
186
 
162
187
  for rule in allow_rules:
163
188
  table.add_row(scope, "[green]allow[/green]", escape(rule))
164
189
  has_rules = True
165
190
 
191
+ for rule in ask_rules:
192
+ table.add_row(scope, "[yellow]ask[/yellow]", escape(rule))
193
+ has_rules = True
194
+
166
195
  for rule in deny_rules:
167
196
  table.add_row(scope, "[red]deny[/red]", escape(rule))
168
197
  has_rules = True
@@ -182,7 +211,7 @@ def _render_all_rules(console: Any, project_path: Path) -> None:
182
211
  def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> None:
183
212
  """Display rules for a specific scope."""
184
213
  heading, config_path = _get_scope_info(scope, project_path)
185
- allow_rules, deny_rules = _get_rules_for_scope(scope, project_path)
214
+ allow_rules, deny_rules, ask_rules = _get_rules_for_scope(scope, project_path)
186
215
 
187
216
  table = Table(title=f"{heading} Permission Rules", show_header=True, header_style="bold cyan")
188
217
  table.add_column("Type", style="dim")
@@ -193,6 +222,10 @@ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> N
193
222
  table.add_row("[green]allow[/green]", escape(rule))
194
223
  has_rules = True
195
224
 
225
+ for rule in ask_rules:
226
+ table.add_row("[yellow]ask[/yellow]", escape(rule))
227
+ has_rules = True
228
+
196
229
  for rule in deny_rules:
197
230
  table.add_row("[red]deny[/red]", escape(rule))
198
231
  has_rules = True
@@ -205,22 +238,37 @@ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> N
205
238
  console.print(f"[dim]Config file: {escape(config_path)}[/dim]")
206
239
 
207
240
 
241
+ def _handle_permissions_tui(ui: Any) -> bool:
242
+ project_path = getattr(ui, "project_path", Path.cwd())
243
+ console = ui.console
244
+ if not sys.stdin.isatty():
245
+ console.print("[yellow]Interactive UI requires a TTY. Showing plain list instead.[/yellow]")
246
+ _render_all_rules(console, project_path)
247
+ return True
248
+
249
+ try:
250
+ from ripperdoc.cli.ui.permissions_tui import run_permissions_tui
251
+ except (ImportError, ModuleNotFoundError) as exc:
252
+ console.print(
253
+ f"[yellow]Textual UI not available ({escape(str(exc))}). Showing plain list.[/yellow]"
254
+ )
255
+ _render_all_rules(console, project_path)
256
+ return True
257
+
258
+ try:
259
+ return bool(run_permissions_tui(project_path))
260
+ except Exception as exc: # noqa: BLE001 - fail safe in interactive UI
261
+ console.print(f"[red]Textual UI failed: {escape(str(exc))}[/red]")
262
+ _render_all_rules(console, project_path)
263
+ return True
264
+
265
+
208
266
  def _handle(ui: Any, trimmed_arg: str) -> bool:
209
267
  project_path = getattr(ui, "project_path", Path.cwd())
210
268
  args = trimmed_arg.strip().split()
211
269
 
212
- # No args: show all rules
213
270
  if not args:
214
- _render_all_rules(ui.console, project_path)
215
- ui.console.print()
216
- ui.console.print("[dim]Usage:[/dim]")
217
- ui.console.print("[dim] /permissions - Show all rules[/dim]")
218
- ui.console.print("[dim] /permissions <scope> - Show rules for scope[/dim]")
219
- ui.console.print("[dim] /permissions add <scope> <type> <rule> - Add a rule[/dim]")
220
- ui.console.print("[dim] /permissions remove <scope> <type> <rule> - Remove a rule[/dim]")
221
- ui.console.print("[dim]Scopes: user, project, local[/dim]")
222
- ui.console.print("[dim]Types: allow, deny[/dim]")
223
- return True
271
+ return _handle_permissions_tui(ui)
224
272
 
225
273
  # Parse command
226
274
  action = args[0].lower()
@@ -233,6 +281,13 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
233
281
  "private": "local",
234
282
  }
235
283
 
284
+ if action in ("tui", "ui"):
285
+ return _handle_permissions_tui(ui)
286
+
287
+ if action in ("list", "ls"):
288
+ _render_all_rules(ui.console, project_path)
289
+ return True
290
+
236
291
  # Single scope display
237
292
  if action in scope_aliases:
238
293
  display_scope: ScopeType = scope_aliases[action]
@@ -254,11 +309,11 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
254
309
  scope: ScopeType = scope_aliases[scope_arg]
255
310
 
256
311
  rule_type_arg = args[2].lower()
257
- if rule_type_arg not in ("allow", "deny"):
312
+ if rule_type_arg not in ("allow", "deny", "ask"):
258
313
  ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
259
- ui.console.print("[dim]Available types: allow, deny[/dim]")
314
+ ui.console.print("[dim]Available types: allow, ask, deny[/dim]")
260
315
  return True
261
- rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
316
+ rule_type: Literal["allow", "deny", "ask"] = rule_type_arg # type: ignore[assignment]
262
317
 
263
318
  rule = " ".join(args[3:])
264
319
  if _add_rule(scope, rule_type, rule, project_path):
@@ -287,11 +342,11 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
287
342
  scope = scope_aliases[scope_arg] # type: ignore
288
343
 
289
344
  rule_type_arg = args[2].lower()
290
- if rule_type_arg not in ("allow", "deny"):
345
+ if rule_type_arg not in ("allow", "deny", "ask"):
291
346
  ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
292
- ui.console.print("[dim]Available types: allow, deny[/dim]")
347
+ ui.console.print("[dim]Available types: allow, ask, deny[/dim]")
293
348
  return True
294
- remove_rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore[assignment]
349
+ remove_rule_type: Literal["allow", "deny", "ask"] = rule_type_arg # type: ignore[assignment]
295
350
 
296
351
  rule = " ".join(args[3:])
297
352
  if _remove_rule(scope, remove_rule_type, rule, project_path):
@@ -10,16 +10,26 @@ from ripperdoc.utils.session_history import (
10
10
  load_session_messages,
11
11
  )
12
12
 
13
+ from ripperdoc.cli.ui.choice import ChoiceOption, prompt_choice, theme_style
14
+
13
15
  from .base import SlashCommand
14
16
 
15
17
  # Number of sessions to display per page
16
- PAGE_SIZE = 20
18
+ PAGE_SIZE = 10
17
19
 
18
20
 
19
21
  def _format_time(dt: datetime) -> str:
20
22
  return dt.strftime("%Y-%m-%d %H:%M")
21
23
 
22
24
 
25
+ def _truncate_prompt(prompt: str, max_length: int = 50) -> str:
26
+ """Truncate prompt text for display."""
27
+ prompt = prompt.strip()
28
+ if len(prompt) > max_length:
29
+ return prompt[:max_length - 3] + "..."
30
+ return prompt
31
+
32
+
23
33
  def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
24
34
  sessions = list_session_summaries(ui.project_path)
25
35
  if not sessions:
@@ -43,64 +53,64 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
43
53
  end_idx = min(start_idx + PAGE_SIZE, len(sessions))
44
54
  page_sessions = sessions[start_idx:end_idx]
45
55
 
46
- ui.console.print(f"\n[bold]Saved sessions (Page {current_page + 1}/{total_pages}):[/bold]")
47
- for idx, summary in enumerate(page_sessions, start=start_idx):
48
- ui.console.print(
49
- f" [{idx}] {summary.session_id} "
50
- f"({summary.message_count} messages, "
51
- f"{_format_time(summary.created_at)} → {_format_time(summary.updated_at)}) "
52
- f"{escape(summary.last_prompt)}",
53
- markup=False,
56
+ # Build choice options
57
+ options = []
58
+ for summary in page_sessions:
59
+ prompt_preview = _truncate_prompt(summary.last_prompt)
60
+ label = (
61
+ f"<info>{summary.session_id[:8]}...</info> "
62
+ f"({summary.message_count} msgs) "
63
+ f"<dim>{_format_time(summary.created_at)}</dim> "
64
+ f"<dim>{escape(prompt_preview)}</dim>"
54
65
  )
66
+ options.append(ChoiceOption(summary.session_id, label))
55
67
 
56
- # Show navigation hints
57
- nav_hints = []
68
+ # Add navigation options
69
+ nav_options = []
58
70
  if current_page > 0:
59
- nav_hints.append("'p' for previous page")
71
+ nav_options.append(ChoiceOption(
72
+ "__prev__",
73
+ "<dim>←</dim> <dim>Previous page</dim>"
74
+ ))
60
75
  if current_page < total_pages - 1:
61
- nav_hints.append("'n' for next page")
62
- nav_hints.append("Enter to cancel")
63
-
64
- prompt = "\nSelect session index"
65
- if nav_hints:
66
- prompt += f" ({', '.join(nav_hints)})"
67
- prompt += ": "
68
-
69
- choice_text = ui.console.input(prompt).strip().lower()
70
-
71
- if not choice_text:
76
+ nav_options.append(ChoiceOption(
77
+ "__next__",
78
+ "<dim>→</dim> <dim>Next page</dim>"
79
+ ))
80
+
81
+ # Build the message
82
+ message = f"<question>Select a session to resume</question> <dim>(Page {current_page + 1}/{total_pages})</dim>"
83
+
84
+ # Combine options: sessions first, then navigation
85
+ all_options = options + nav_options
86
+
87
+ try:
88
+ result = prompt_choice(
89
+ message=message,
90
+ options=all_options,
91
+ title="Resume Session",
92
+ allow_esc=True,
93
+ esc_value="__cancel__",
94
+ style=theme_style(),
95
+ )
96
+ except (EOFError, KeyboardInterrupt):
72
97
  return None
73
98
 
74
- # Handle pagination commands
75
- if choice_text == "n":
76
- if current_page < total_pages - 1:
77
- current_page += 1
78
- continue
79
- else:
80
- ui.console.print("[yellow]Already at the last page.[/yellow]")
81
- continue
82
- elif choice_text == "p":
83
- if current_page > 0:
84
- current_page -= 1
85
- continue
86
- else:
87
- ui.console.print("[yellow]Already at the first page.[/yellow]")
88
- continue
89
-
90
- # Handle session selection
91
- if not choice_text.isdigit():
92
- ui.console.print(
93
- "[red]Please enter a session index number, 'n' for next page, or 'p' for previous page.[/red]"
94
- )
99
+ # Handle the result
100
+ if result == "__cancel__":
101
+ return None
102
+ elif result == "__next__":
103
+ current_page += 1
95
104
  continue
96
-
97
- idx = int(choice_text)
98
- if idx < 0 or idx >= len(sessions):
99
- ui.console.print(
100
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
101
- )
105
+ elif result == "__prev__":
106
+ current_page -= 1
102
107
  continue
103
- return sessions[idx]
108
+ else:
109
+ # Find and return the selected session
110
+ for s in sessions:
111
+ if s.session_id == result:
112
+ return s
113
+ return None
104
114
 
105
115
 
106
116
  def _handle(ui: Any, arg: str) -> bool:
@@ -8,6 +8,7 @@ from typing import Any
8
8
  from rich.markup import escape
9
9
  from rich.table import Table
10
10
 
11
+ from ripperdoc.cli.ui.choice import ChoiceOption, prompt_choice, theme_style
11
12
  from ripperdoc.core.config import get_global_config, save_global_config
12
13
  from ripperdoc.core.theme import (
13
14
  BUILTIN_THEMES,
@@ -111,8 +112,37 @@ def _handle(ui: Any, arg: str) -> bool:
111
112
  arg = arg.strip().lower()
112
113
 
113
114
  if not arg:
115
+ # Show current theme
114
116
  _show_current_theme(ui)
115
- _list_themes(ui)
117
+
118
+ # Use choice component to select theme
119
+ manager = get_theme_manager()
120
+ current_name = manager.current.name
121
+
122
+ options = [
123
+ ChoiceOption(
124
+ name,
125
+ f"<info>{theme.display_name}</info>",
126
+ theme.description,
127
+ is_default=(name == current_name),
128
+ )
129
+ for name, theme in BUILTIN_THEMES.items()
130
+ ]
131
+
132
+ selected = prompt_choice(
133
+ message="<question>Choose a theme:</question>",
134
+ options=options,
135
+ title="Available Themes",
136
+ allow_esc=True,
137
+ esc_value=current_name, # ESC keeps current theme
138
+ style=theme_style(),
139
+ )
140
+
141
+ if selected and selected != current_name:
142
+ _switch_theme(ui, selected)
143
+ elif selected == current_name:
144
+ ui.console.print(f"[dim]Theme remains {manager.current.display_name}[/dim]")
145
+
116
146
  return True
117
147
 
118
148
  parts = arg.split()
@@ -0,0 +1,3 @@
1
+ from .textual_app import run_agents_tui
2
+
3
+ __all__ = ["run_agents_tui"]