ripperdoc 0.2.0__py3-none-any.whl → 0.2.3__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 (65) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +74 -9
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +30 -4
  5. ripperdoc/cli/commands/context_cmd.py +11 -1
  6. ripperdoc/cli/commands/cost_cmd.py +5 -0
  7. ripperdoc/cli/commands/doctor_cmd.py +208 -0
  8. ripperdoc/cli/commands/memory_cmd.py +202 -0
  9. ripperdoc/cli/commands/models_cmd.py +61 -6
  10. ripperdoc/cli/commands/resume_cmd.py +4 -2
  11. ripperdoc/cli/commands/status_cmd.py +1 -1
  12. ripperdoc/cli/commands/tasks_cmd.py +27 -0
  13. ripperdoc/cli/ui/rich_ui.py +258 -11
  14. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  15. ripperdoc/core/agents.py +14 -4
  16. ripperdoc/core/config.py +56 -3
  17. ripperdoc/core/default_tools.py +16 -2
  18. ripperdoc/core/permissions.py +19 -0
  19. ripperdoc/core/providers/__init__.py +31 -0
  20. ripperdoc/core/providers/anthropic.py +136 -0
  21. ripperdoc/core/providers/base.py +187 -0
  22. ripperdoc/core/providers/gemini.py +172 -0
  23. ripperdoc/core/providers/openai.py +142 -0
  24. ripperdoc/core/query.py +510 -386
  25. ripperdoc/core/query_utils.py +578 -0
  26. ripperdoc/core/system_prompt.py +2 -1
  27. ripperdoc/core/tool.py +16 -1
  28. ripperdoc/sdk/client.py +12 -1
  29. ripperdoc/tools/background_shell.py +63 -21
  30. ripperdoc/tools/bash_tool.py +48 -13
  31. ripperdoc/tools/file_edit_tool.py +20 -0
  32. ripperdoc/tools/file_read_tool.py +23 -0
  33. ripperdoc/tools/file_write_tool.py +20 -0
  34. ripperdoc/tools/glob_tool.py +59 -15
  35. ripperdoc/tools/grep_tool.py +7 -0
  36. ripperdoc/tools/ls_tool.py +246 -73
  37. ripperdoc/tools/mcp_tools.py +32 -10
  38. ripperdoc/tools/multi_edit_tool.py +23 -0
  39. ripperdoc/tools/notebook_edit_tool.py +18 -3
  40. ripperdoc/tools/task_tool.py +7 -0
  41. ripperdoc/tools/todo_tool.py +157 -25
  42. ripperdoc/tools/tool_search_tool.py +17 -4
  43. ripperdoc/utils/file_watch.py +134 -0
  44. ripperdoc/utils/git_utils.py +274 -0
  45. ripperdoc/utils/json_utils.py +27 -0
  46. ripperdoc/utils/log.py +129 -29
  47. ripperdoc/utils/mcp.py +71 -6
  48. ripperdoc/utils/memory.py +12 -1
  49. ripperdoc/utils/message_compaction.py +22 -5
  50. ripperdoc/utils/messages.py +72 -17
  51. ripperdoc/utils/output_utils.py +34 -9
  52. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  53. ripperdoc/utils/prompt.py +17 -0
  54. ripperdoc/utils/safe_get_cwd.py +4 -0
  55. ripperdoc/utils/session_history.py +27 -9
  56. ripperdoc/utils/session_usage.py +7 -0
  57. ripperdoc/utils/shell_utils.py +159 -0
  58. ripperdoc/utils/todo.py +2 -2
  59. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +4 -2
  60. ripperdoc-0.2.3.dist-info/RECORD +95 -0
  61. ripperdoc-0.2.0.dist-info/RECORD +0 -81
  62. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
  63. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
  64. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
  65. {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
@@ -0,0 +1,202 @@
1
+ """Slash command to view and edit AGENTS memory files."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import os
6
+ import shlex
7
+ import shutil
8
+ import subprocess
9
+ from pathlib import Path
10
+ from typing import Any, List, Optional
11
+
12
+ from rich.markup import escape
13
+ from rich.panel import Panel
14
+ from rich.table import Table
15
+
16
+ from ripperdoc.utils.memory import (
17
+ LOCAL_MEMORY_FILE_NAME,
18
+ MEMORY_FILE_NAME,
19
+ collect_all_memory_files,
20
+ )
21
+
22
+ from .base import SlashCommand
23
+
24
+
25
+ def _shorten_path(path: Path, project_path: Path) -> str:
26
+ """Return a short, user-friendly path."""
27
+ try:
28
+ return str(path.resolve().relative_to(project_path.resolve()))
29
+ except Exception:
30
+ pass
31
+
32
+ home = Path.home()
33
+ try:
34
+ rel_home = path.resolve().relative_to(home)
35
+ return f"~/{rel_home}"
36
+ except Exception:
37
+ return str(path)
38
+
39
+
40
+ def _preferred_user_memory_path() -> Path:
41
+ """Pick the user-level memory path, preferring ~/.ripperdoc/AGENTS.md."""
42
+ home = Path.home()
43
+ preferred_dir = home / ".ripperdoc"
44
+ preferred_path = preferred_dir / MEMORY_FILE_NAME
45
+ fallback_path = home / MEMORY_FILE_NAME
46
+
47
+ if preferred_path.exists():
48
+ return preferred_path
49
+ if fallback_path.exists():
50
+ return fallback_path
51
+
52
+ preferred_dir.mkdir(parents=True, exist_ok=True)
53
+ return preferred_path
54
+
55
+
56
+ def _ensure_file(path: Path) -> bool:
57
+ """Ensure the target file exists. Returns True if created."""
58
+ path.parent.mkdir(parents=True, exist_ok=True)
59
+ if path.exists():
60
+ return False
61
+ path.write_text("", encoding="utf-8")
62
+ return True
63
+
64
+
65
+ def _ensure_gitignore_entry(project_path: Path, entry: str) -> bool:
66
+ """Ensure an entry exists in .gitignore. Returns True if added."""
67
+ gitignore_path = project_path / ".gitignore"
68
+ try:
69
+ text = ""
70
+ if gitignore_path.exists():
71
+ text = gitignore_path.read_text(encoding="utf-8", errors="ignore")
72
+ existing_lines = text.splitlines()
73
+ if entry in existing_lines:
74
+ return False
75
+ with gitignore_path.open("a", encoding="utf-8") as f:
76
+ if text and not text.endswith("\n"):
77
+ f.write("\n")
78
+ f.write(f"{entry}\n")
79
+ return True
80
+ except Exception:
81
+ return False
82
+
83
+
84
+ def _determine_editor_command() -> Optional[List[str]]:
85
+ """Resolve the editor command from environment or common defaults."""
86
+ for env_var in ("VISUAL", "EDITOR"):
87
+ value = os.environ.get(env_var)
88
+ if value:
89
+ return shlex.split(value)
90
+
91
+ candidates = ["code", "nano", "vim", "vi"]
92
+ if os.name == "nt":
93
+ candidates.insert(0, "notepad")
94
+
95
+ for candidate in candidates:
96
+ if shutil.which(candidate):
97
+ return [candidate]
98
+ return None
99
+
100
+
101
+ def _open_in_editor(path: Path, console: Any) -> bool:
102
+ """Open the file in a text editor; returns True if an editor was launched."""
103
+ editor_cmd = _determine_editor_command()
104
+ if not editor_cmd:
105
+ console.print(
106
+ f"[yellow]No editor configured. Set $EDITOR or $VISUAL, "
107
+ f"or manually edit: {escape(str(path))}[/yellow]"
108
+ )
109
+ return False
110
+
111
+ cmd = [*editor_cmd, str(path)]
112
+ try:
113
+ console.print(f"[dim]Opening with: {' '.join(editor_cmd)}[/dim]")
114
+ subprocess.run(cmd, check=False)
115
+ return True
116
+ except FileNotFoundError:
117
+ console.print(f"[red]Editor command not found: {escape(editor_cmd[0])}[/red]")
118
+ return False
119
+ except Exception as exc: # pragma: no cover - best-effort logging
120
+ console.print(f"[red]Failed to launch editor: {escape(str(exc))}[/red]")
121
+ return False
122
+
123
+
124
+ def _render_memory_table(console: Any, project_path: Path) -> None:
125
+ files = collect_all_memory_files()
126
+ table = Table(title="Memory files", show_header=True, header_style="bold cyan")
127
+ table.add_column("Type", style="bold")
128
+ table.add_column("Location")
129
+ table.add_column("Nested", justify="center")
130
+
131
+ for memory_file in files:
132
+ display_path = _shorten_path(Path(memory_file.path), project_path)
133
+ nested = "yes" if getattr(memory_file, "is_nested", False) else ""
134
+ table.add_row(memory_file.type, escape(display_path), nested)
135
+
136
+ if files:
137
+ console.print(table)
138
+ else:
139
+ console.print("[yellow]No memory files found yet.[/yellow]")
140
+
141
+
142
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
143
+ project_path = getattr(ui, "project_path", Path.cwd())
144
+ scope = trimmed_arg.strip().lower()
145
+
146
+ if scope:
147
+ scope_aliases = {
148
+ "project": "project",
149
+ "workspace": "project",
150
+ "local": "local",
151
+ "private": "local",
152
+ "user": "user",
153
+ "global": "user",
154
+ }
155
+ if scope not in scope_aliases:
156
+ ui.console.print("[red]Unknown scope. Use one of: project, local, user.[/red]")
157
+ return True
158
+
159
+ resolved_scope = scope_aliases[scope]
160
+ if resolved_scope == "project":
161
+ target_path = project_path / MEMORY_FILE_NAME
162
+ heading = "Project memory (checked in)"
163
+ elif resolved_scope == "local":
164
+ target_path = project_path / LOCAL_MEMORY_FILE_NAME
165
+ heading = "Local memory (not checked in)"
166
+ else:
167
+ target_path = _preferred_user_memory_path()
168
+ heading = "User memory (home directory)"
169
+
170
+ created = _ensure_file(target_path)
171
+ gitignore_added = False
172
+ if resolved_scope == "local":
173
+ gitignore_added = _ensure_gitignore_entry(project_path, LOCAL_MEMORY_FILE_NAME)
174
+
175
+ _open_in_editor(target_path, ui.console)
176
+
177
+ messages: List[str] = [f"{heading}: {escape(_shorten_path(target_path, project_path))}"]
178
+ if created:
179
+ messages.append("Created new memory file.")
180
+ if gitignore_added:
181
+ messages.append("Added AGENTS.local.md to .gitignore.")
182
+ if not created:
183
+ messages.append("Opened existing memory file.")
184
+
185
+ ui.console.print(Panel("\n".join(messages), title="/memory"))
186
+ return True
187
+
188
+ _render_memory_table(ui.console, project_path)
189
+ ui.console.print("[dim]Usage: /memory project | /memory local | /memory user[/dim]")
190
+ ui.console.print("[dim]Project and user memories feed directly into the system prompt.[/dim]")
191
+ return True
192
+
193
+
194
+ command = SlashCommand(
195
+ name="memory",
196
+ description="List and edit AGENTS memory files",
197
+ handler=_handle,
198
+ aliases=("mem",),
199
+ )
200
+
201
+
202
+ __all__ = ["command"]
@@ -1,6 +1,4 @@
1
- from typing import Any
2
- from getpass import getpass
3
- from typing import Optional
1
+ from typing import Any, Optional
4
2
 
5
3
  from rich.markup import escape
6
4
 
@@ -13,15 +11,23 @@ from ripperdoc.core.config import (
13
11
  get_global_config,
14
12
  set_model_pointer,
15
13
  )
14
+ from ripperdoc.utils.log import get_logger
15
+ from ripperdoc.utils.prompt import prompt_secret
16
16
 
17
17
  from .base import SlashCommand
18
18
 
19
+ logger = get_logger()
20
+
19
21
 
20
22
  def _handle(ui: Any, trimmed_arg: str) -> bool:
21
23
  console = ui.console
22
24
  tokens = trimmed_arg.split()
23
25
  subcmd = tokens[0].lower() if tokens else ""
24
26
  config = get_global_config()
27
+ logger.info(
28
+ "[models_cmd] Handling /models command",
29
+ extra={"subcommand": subcmd or "list", "session_id": getattr(ui, "session_id", None)},
30
+ )
25
31
 
26
32
  def print_models_usage() -> None:
27
33
  console.print("[bold]/models[/bold] — list configured models")
@@ -103,9 +109,18 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
103
109
  console.print("[red]Model name is required.[/red]")
104
110
  return True
105
111
 
106
- api_key_input = getpass("API key (leave blank to keep unset): ").strip()
112
+ api_key_input = prompt_secret("API key (leave blank to keep unset)").strip()
107
113
  api_key = api_key_input or (existing_profile.api_key if existing_profile else None)
108
114
 
115
+ auth_token = existing_profile.auth_token if existing_profile else None
116
+ if provider == ProviderType.ANTHROPIC:
117
+ auth_token_input = prompt_secret(
118
+ "Auth token (Anthropic only, leave blank to keep unset)"
119
+ ).strip()
120
+ auth_token = auth_token_input or auth_token
121
+ else:
122
+ auth_token = None
123
+
109
124
  api_base_default = existing_profile.api_base if existing_profile else ""
110
125
  api_base = (
111
126
  console.input(
@@ -156,6 +171,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
156
171
  max_tokens=max_tokens,
157
172
  temperature=temperature,
158
173
  context_window=context_window,
174
+ auth_token=auth_token,
159
175
  )
160
176
 
161
177
  try:
@@ -167,6 +183,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
167
183
  )
168
184
  except Exception as exc:
169
185
  console.print(f"[red]Failed to save model: {escape(str(exc))}[/red]")
186
+ logger.exception(
187
+ "[models_cmd] Failed to save model profile",
188
+ extra={"profile": profile_name, "session_id": getattr(ui, "session_id", None)},
189
+ )
170
190
  return True
171
191
 
172
192
  marker = " (main)" if set_as_main else ""
@@ -202,8 +222,8 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
202
222
  )
203
223
 
204
224
  api_key_label = "[set]" if existing_profile.api_key else "[not set]"
205
- api_key_prompt = f"API key {api_key_label} (Enter=keep, '-'=clear): "
206
- api_key_input = getpass(api_key_prompt).strip()
225
+ api_key_prompt = f"API key {api_key_label} (Enter=keep, '-'=clear)"
226
+ api_key_input = prompt_secret(api_key_prompt).strip()
207
227
  if api_key_input == "-":
208
228
  api_key = None
209
229
  elif api_key_input:
@@ -211,6 +231,21 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
211
231
  else:
212
232
  api_key = existing_profile.api_key
213
233
 
234
+ auth_token = existing_profile.auth_token
235
+ if (
236
+ provider == ProviderType.ANTHROPIC
237
+ or existing_profile.provider == ProviderType.ANTHROPIC
238
+ ):
239
+ auth_label = "[set]" if auth_token else "[not set]"
240
+ auth_prompt = f"Auth token (Anthropic only) {auth_label} (Enter=keep, '-'=clear)"
241
+ auth_token_input = prompt_secret(auth_prompt).strip()
242
+ if auth_token_input == "-":
243
+ auth_token = None
244
+ elif auth_token_input:
245
+ auth_token = auth_token_input
246
+ else:
247
+ auth_token = None
248
+
214
249
  api_base = (
215
250
  console.input(f"API base (optional) [{existing_profile.api_base or ''}]: ").strip()
216
251
  or existing_profile.api_base
@@ -244,6 +279,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
244
279
  max_tokens=max_tokens,
245
280
  temperature=temperature,
246
281
  context_window=context_window,
282
+ auth_token=auth_token,
247
283
  )
248
284
 
249
285
  try:
@@ -255,6 +291,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
255
291
  )
256
292
  except Exception as exc:
257
293
  console.print(f"[red]Failed to update model: {escape(str(exc))}[/red]")
294
+ logger.exception(
295
+ "[models_cmd] Failed to update model profile",
296
+ extra={"profile": profile_name, "session_id": getattr(ui, "session_id", None)},
297
+ )
258
298
  return True
259
299
 
260
300
  console.print(f"[green]✓ Model '{escape(profile_name)}' updated[/green]")
@@ -274,6 +314,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
274
314
  except Exception as exc:
275
315
  console.print(f"[red]Failed to delete model: {escape(str(exc))}[/red]")
276
316
  print_models_usage()
317
+ logger.exception(
318
+ "[models_cmd] Failed to delete model profile",
319
+ extra={"profile": target, "session_id": getattr(ui, "session_id", None)},
320
+ )
277
321
  return True
278
322
 
279
323
  if subcmd in ("use", "main", "set-main"):
@@ -288,6 +332,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
288
332
  except Exception as exc:
289
333
  console.print(f"[red]{escape(str(exc))}[/red]")
290
334
  print_models_usage()
335
+ logger.exception(
336
+ "[models_cmd] Failed to set main model pointer",
337
+ extra={"profile": target, "session_id": getattr(ui, "session_id", None)},
338
+ )
291
339
  return True
292
340
 
293
341
  print_models_usage()
@@ -312,6 +360,13 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
312
360
  markup=False,
313
361
  )
314
362
  console.print(f" api_key: {'***' if profile.api_key else 'Not set'}", markup=False)
363
+ if profile.provider == ProviderType.ANTHROPIC:
364
+ console.print(
365
+ f" auth_token: {'***' if getattr(profile, 'auth_token', None) else 'Not set'}",
366
+ markup=False,
367
+ )
368
+ if profile.openai_tool_mode:
369
+ console.print(f" openai_tool_mode: {profile.openai_tool_mode}", markup=False)
315
370
  pointer_labels = ", ".join(f"{p}->{v or '-'}" for p, v in pointer_map.items())
316
371
  console.print(f"[dim]Pointers: {escape(pointer_labels)}[/dim]")
317
372
  return True
@@ -30,7 +30,8 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
30
30
  if 0 <= idx < len(sessions):
31
31
  return sessions[idx]
32
32
  ui.console.print(
33
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
33
+ f"[red]Invalid session index {escape(str(idx))}. "
34
+ f"Choose 0-{len(sessions) - 1}.[/red]"
34
35
  )
35
36
  else:
36
37
  # Treat arg as session id if it matches.
@@ -60,7 +61,8 @@ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
60
61
  idx = int(choice_text)
61
62
  if idx < 0 or idx >= len(sessions):
62
63
  ui.console.print(
63
- f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
64
+ f"[red]Invalid session index {escape(str(idx))}. "
65
+ f"Choose 0-{len(sessions) - 1}.[/red]"
64
66
  )
65
67
  return None
66
68
  return sessions[idx]
@@ -34,7 +34,7 @@ def _auth_token_display(profile: Optional[ModelProfile]) -> Tuple[str, Optional[
34
34
  env_var = next((name for name in env_candidates if os.environ.get(name)), None)
35
35
  if env_var:
36
36
  return (f"{env_var} (env)", env_var)
37
- if profile.api_key:
37
+ if profile.api_key or getattr(profile, "auth_token", None):
38
38
  return ("Configured in profile", None)
39
39
  return ("Missing", None)
40
40
 
@@ -13,11 +13,15 @@ from ripperdoc.tools.background_shell import (
13
13
  kill_background_task,
14
14
  list_background_tasks,
15
15
  )
16
+ from ripperdoc.utils.log import get_logger
16
17
 
17
18
  from typing import Any, Optional
18
19
  from .base import SlashCommand
19
20
 
20
21
 
22
+ logger = get_logger()
23
+
24
+
21
25
  def _format_duration(duration_ms: Optional[float]) -> str:
22
26
  """Render milliseconds into a short human-readable duration."""
23
27
  if duration_ms is None:
@@ -103,6 +107,10 @@ def _list_tasks(ui: Any) -> bool:
103
107
  status = get_background_status(task_id, consume=False)
104
108
  except Exception as exc:
105
109
  table.add_row(escape(task_id), "[red]error[/]", escape(str(exc)), "-")
110
+ logger.exception(
111
+ "[tasks_cmd] Failed to read background task status",
112
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
113
+ )
106
114
  continue
107
115
 
108
116
  command = status.get("command") or ""
@@ -137,6 +145,10 @@ def _kill_task(ui: Any, task_id: str) -> bool:
137
145
  return True
138
146
  except Exception as exc:
139
147
  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",
150
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
151
+ )
140
152
  return True
141
153
 
142
154
  if status.get("status") != "running":
@@ -149,6 +161,10 @@ def _kill_task(ui: Any, task_id: str) -> bool:
149
161
  killed = asyncio.run(kill_background_task(task_id))
150
162
  except Exception as exc:
151
163
  console.print(f"[red]Error stopping task {escape(task_id)}: {escape(str(exc))}[/red]")
164
+ logger.exception(
165
+ "[tasks_cmd] Error stopping background task",
166
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
167
+ )
152
168
  return True
153
169
 
154
170
  if killed:
@@ -169,6 +185,10 @@ def _show_task(ui: Any, task_id: str) -> bool:
169
185
  return True
170
186
  except Exception as exc:
171
187
  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",
190
+ extra={"task_id": task_id, "session_id": getattr(ui, "session_id", None)},
191
+ )
172
192
  return True
173
193
 
174
194
  details = Table(box=box.SIMPLE_HEAVY, show_header=False)
@@ -208,6 +228,13 @@ def _show_task(ui: Any, task_id: str) -> bool:
208
228
 
209
229
  def _handle(ui: Any, args: str) -> bool:
210
230
  parts = args.split()
231
+ logger.info(
232
+ "[tasks_cmd] Handling /tasks command",
233
+ extra={
234
+ "session_id": getattr(ui, "session_id", None),
235
+ "raw_args": args,
236
+ },
237
+ )
211
238
  if not parts:
212
239
  return _list_tasks(ui)
213
240