ripperdoc 0.2.6__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 (107) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +20 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +405 -0
  5. ripperdoc/cli/commands/__init__.py +82 -0
  6. ripperdoc/cli/commands/agents_cmd.py +263 -0
  7. ripperdoc/cli/commands/base.py +19 -0
  8. ripperdoc/cli/commands/clear_cmd.py +18 -0
  9. ripperdoc/cli/commands/compact_cmd.py +23 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +144 -0
  12. ripperdoc/cli/commands/cost_cmd.py +82 -0
  13. ripperdoc/cli/commands/doctor_cmd.py +221 -0
  14. ripperdoc/cli/commands/exit_cmd.py +19 -0
  15. ripperdoc/cli/commands/help_cmd.py +20 -0
  16. ripperdoc/cli/commands/mcp_cmd.py +70 -0
  17. ripperdoc/cli/commands/memory_cmd.py +202 -0
  18. ripperdoc/cli/commands/models_cmd.py +413 -0
  19. ripperdoc/cli/commands/permissions_cmd.py +302 -0
  20. ripperdoc/cli/commands/resume_cmd.py +98 -0
  21. ripperdoc/cli/commands/status_cmd.py +167 -0
  22. ripperdoc/cli/commands/tasks_cmd.py +278 -0
  23. ripperdoc/cli/commands/todos_cmd.py +69 -0
  24. ripperdoc/cli/commands/tools_cmd.py +19 -0
  25. ripperdoc/cli/ui/__init__.py +1 -0
  26. ripperdoc/cli/ui/context_display.py +298 -0
  27. ripperdoc/cli/ui/helpers.py +22 -0
  28. ripperdoc/cli/ui/rich_ui.py +1557 -0
  29. ripperdoc/cli/ui/spinner.py +49 -0
  30. ripperdoc/cli/ui/thinking_spinner.py +128 -0
  31. ripperdoc/cli/ui/tool_renderers.py +298 -0
  32. ripperdoc/core/__init__.py +1 -0
  33. ripperdoc/core/agents.py +486 -0
  34. ripperdoc/core/commands.py +33 -0
  35. ripperdoc/core/config.py +559 -0
  36. ripperdoc/core/default_tools.py +88 -0
  37. ripperdoc/core/permissions.py +252 -0
  38. ripperdoc/core/providers/__init__.py +47 -0
  39. ripperdoc/core/providers/anthropic.py +250 -0
  40. ripperdoc/core/providers/base.py +265 -0
  41. ripperdoc/core/providers/gemini.py +615 -0
  42. ripperdoc/core/providers/openai.py +487 -0
  43. ripperdoc/core/query.py +1058 -0
  44. ripperdoc/core/query_utils.py +622 -0
  45. ripperdoc/core/skills.py +295 -0
  46. ripperdoc/core/system_prompt.py +431 -0
  47. ripperdoc/core/tool.py +240 -0
  48. ripperdoc/sdk/__init__.py +9 -0
  49. ripperdoc/sdk/client.py +333 -0
  50. ripperdoc/tools/__init__.py +1 -0
  51. ripperdoc/tools/ask_user_question_tool.py +431 -0
  52. ripperdoc/tools/background_shell.py +389 -0
  53. ripperdoc/tools/bash_output_tool.py +98 -0
  54. ripperdoc/tools/bash_tool.py +1016 -0
  55. ripperdoc/tools/dynamic_mcp_tool.py +428 -0
  56. ripperdoc/tools/enter_plan_mode_tool.py +226 -0
  57. ripperdoc/tools/exit_plan_mode_tool.py +153 -0
  58. ripperdoc/tools/file_edit_tool.py +346 -0
  59. ripperdoc/tools/file_read_tool.py +203 -0
  60. ripperdoc/tools/file_write_tool.py +205 -0
  61. ripperdoc/tools/glob_tool.py +179 -0
  62. ripperdoc/tools/grep_tool.py +370 -0
  63. ripperdoc/tools/kill_bash_tool.py +136 -0
  64. ripperdoc/tools/ls_tool.py +471 -0
  65. ripperdoc/tools/mcp_tools.py +591 -0
  66. ripperdoc/tools/multi_edit_tool.py +456 -0
  67. ripperdoc/tools/notebook_edit_tool.py +386 -0
  68. ripperdoc/tools/skill_tool.py +205 -0
  69. ripperdoc/tools/task_tool.py +379 -0
  70. ripperdoc/tools/todo_tool.py +494 -0
  71. ripperdoc/tools/tool_search_tool.py +380 -0
  72. ripperdoc/utils/__init__.py +1 -0
  73. ripperdoc/utils/bash_constants.py +51 -0
  74. ripperdoc/utils/bash_output_utils.py +43 -0
  75. ripperdoc/utils/coerce.py +34 -0
  76. ripperdoc/utils/context_length_errors.py +252 -0
  77. ripperdoc/utils/exit_code_handlers.py +241 -0
  78. ripperdoc/utils/file_watch.py +135 -0
  79. ripperdoc/utils/git_utils.py +274 -0
  80. ripperdoc/utils/json_utils.py +27 -0
  81. ripperdoc/utils/log.py +176 -0
  82. ripperdoc/utils/mcp.py +560 -0
  83. ripperdoc/utils/memory.py +253 -0
  84. ripperdoc/utils/message_compaction.py +676 -0
  85. ripperdoc/utils/messages.py +519 -0
  86. ripperdoc/utils/output_utils.py +258 -0
  87. ripperdoc/utils/path_ignore.py +677 -0
  88. ripperdoc/utils/path_utils.py +46 -0
  89. ripperdoc/utils/permissions/__init__.py +27 -0
  90. ripperdoc/utils/permissions/path_validation_utils.py +174 -0
  91. ripperdoc/utils/permissions/shell_command_validation.py +552 -0
  92. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  93. ripperdoc/utils/prompt.py +17 -0
  94. ripperdoc/utils/safe_get_cwd.py +31 -0
  95. ripperdoc/utils/sandbox_utils.py +38 -0
  96. ripperdoc/utils/session_history.py +260 -0
  97. ripperdoc/utils/session_usage.py +117 -0
  98. ripperdoc/utils/shell_token_utils.py +95 -0
  99. ripperdoc/utils/shell_utils.py +159 -0
  100. ripperdoc/utils/todo.py +203 -0
  101. ripperdoc/utils/token_estimation.py +34 -0
  102. ripperdoc-0.2.6.dist-info/METADATA +193 -0
  103. ripperdoc-0.2.6.dist-info/RECORD +107 -0
  104. ripperdoc-0.2.6.dist-info/WHEEL +5 -0
  105. ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
  106. ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
  107. ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
@@ -0,0 +1,302 @@
1
+ """Slash command to manage permission rules."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from pathlib import Path
6
+ from typing import Any, List, Literal
7
+
8
+ from rich.markup import escape
9
+ from rich.panel import Panel
10
+ from rich.table import Table
11
+
12
+ from ripperdoc.core.config import (
13
+ config_manager,
14
+ get_global_config,
15
+ get_project_config,
16
+ get_project_local_config,
17
+ save_global_config,
18
+ save_project_config,
19
+ save_project_local_config,
20
+ )
21
+
22
+ from .base import SlashCommand
23
+
24
+
25
+ ScopeType = Literal["user", "project", "local"]
26
+
27
+
28
+ def _shorten_path(path: Path, project_path: Path) -> str:
29
+ """Return a short, user-friendly path."""
30
+ try:
31
+ return str(path.resolve().relative_to(project_path.resolve()))
32
+ except (ValueError, OSError):
33
+ pass
34
+
35
+ home = Path.home()
36
+ try:
37
+ rel_home = path.resolve().relative_to(home)
38
+ return f"~/{rel_home}"
39
+ except (ValueError, OSError):
40
+ return str(path)
41
+
42
+
43
+ def _get_scope_info(scope: ScopeType, project_path: Path) -> tuple[str, str]:
44
+ """Return (heading, config_path) for a given scope."""
45
+ if scope == "user":
46
+ return "User (global)", str(Path.home() / ".ripperdoc.json")
47
+ elif scope == "project":
48
+ return "Project (shared)", str(project_path / ".ripperdoc" / "config.json")
49
+ else: # local
50
+ return "Local (private)", str(project_path / ".ripperdoc" / "config.local.json")
51
+
52
+
53
+ def _get_rules_for_scope(
54
+ scope: ScopeType, project_path: Path
55
+ ) -> tuple[List[str], List[str]]:
56
+ """Return (allow_rules, deny_rules) for a given scope."""
57
+ if scope == "user":
58
+ config = get_global_config()
59
+ return list(config.user_allow_rules), list(config.user_deny_rules)
60
+ elif scope == "project":
61
+ config = get_project_config(project_path)
62
+ return list(config.bash_allow_rules), list(config.bash_deny_rules)
63
+ else: # local
64
+ config = get_project_local_config(project_path)
65
+ return list(config.local_allow_rules), list(config.local_deny_rules)
66
+
67
+
68
+ def _add_rule(
69
+ scope: ScopeType,
70
+ rule_type: Literal["allow", "deny"],
71
+ rule: str,
72
+ project_path: Path,
73
+ ) -> bool:
74
+ """Add a rule to the specified scope. Returns True if added, False if already exists."""
75
+ if scope == "user":
76
+ config = get_global_config()
77
+ rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_rules
78
+ if rule in rules:
79
+ return False
80
+ rules.append(rule)
81
+ save_global_config(config)
82
+ elif scope == "project":
83
+ config = get_project_config(project_path)
84
+ rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_rules
85
+ if rule in rules:
86
+ return False
87
+ rules.append(rule)
88
+ save_project_config(config, project_path)
89
+ else: # local
90
+ config = get_project_local_config(project_path)
91
+ rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_rules
92
+ if rule in rules:
93
+ return False
94
+ rules.append(rule)
95
+ save_project_local_config(config, project_path)
96
+ return True
97
+
98
+
99
+ def _remove_rule(
100
+ scope: ScopeType,
101
+ rule_type: Literal["allow", "deny"],
102
+ rule: str,
103
+ project_path: Path,
104
+ ) -> bool:
105
+ """Remove a rule from the specified scope. Returns True if removed, False if not found."""
106
+ if scope == "user":
107
+ config = get_global_config()
108
+ rules = config.user_allow_rules if rule_type == "allow" else config.user_deny_rules
109
+ if rule not in rules:
110
+ return False
111
+ rules.remove(rule)
112
+ save_global_config(config)
113
+ elif scope == "project":
114
+ config = get_project_config(project_path)
115
+ rules = config.bash_allow_rules if rule_type == "allow" else config.bash_deny_rules
116
+ if rule not in rules:
117
+ return False
118
+ rules.remove(rule)
119
+ save_project_config(config, project_path)
120
+ else: # local
121
+ config = get_project_local_config(project_path)
122
+ rules = config.local_allow_rules if rule_type == "allow" else config.local_deny_rules
123
+ if rule not in rules:
124
+ return False
125
+ rules.remove(rule)
126
+ save_project_local_config(config, project_path)
127
+ return True
128
+
129
+
130
+ def _render_all_rules(console: Any, project_path: Path) -> None:
131
+ """Display all permission rules from all scopes."""
132
+ table = Table(title="Permission Rules", show_header=True, header_style="bold cyan")
133
+ table.add_column("Scope", style="bold")
134
+ table.add_column("Type", style="dim")
135
+ table.add_column("Rule")
136
+
137
+ has_rules = False
138
+
139
+ for scope in ("user", "project", "local"):
140
+ allow_rules, deny_rules = _get_rules_for_scope(scope, project_path) # type: ignore
141
+
142
+ for rule in allow_rules:
143
+ table.add_row(scope, "[green]allow[/green]", escape(rule))
144
+ has_rules = True
145
+
146
+ for rule in deny_rules:
147
+ table.add_row(scope, "[red]deny[/red]", escape(rule))
148
+ has_rules = True
149
+
150
+ if has_rules:
151
+ console.print(table)
152
+ else:
153
+ console.print("[yellow]No permission rules configured yet.[/yellow]")
154
+
155
+ console.print()
156
+ console.print("[dim]Scopes (in priority order):[/dim]")
157
+ console.print("[dim] - user: Global rules (~/.ripperdoc.json)[/dim]")
158
+ console.print("[dim] - project: Shared project rules (.ripperdoc/config.json)[/dim]")
159
+ console.print("[dim] - local: Private project rules (.ripperdoc/config.local.json)[/dim]")
160
+
161
+
162
+ def _render_scope_rules(console: Any, scope: ScopeType, project_path: Path) -> None:
163
+ """Display rules for a specific scope."""
164
+ heading, config_path = _get_scope_info(scope, project_path)
165
+ allow_rules, deny_rules = _get_rules_for_scope(scope, project_path)
166
+
167
+ table = Table(title=f"{heading} Permission Rules", show_header=True, header_style="bold cyan")
168
+ table.add_column("Type", style="dim")
169
+ table.add_column("Rule")
170
+
171
+ has_rules = False
172
+ for rule in allow_rules:
173
+ table.add_row("[green]allow[/green]", escape(rule))
174
+ has_rules = True
175
+
176
+ for rule in deny_rules:
177
+ table.add_row("[red]deny[/red]", escape(rule))
178
+ has_rules = True
179
+
180
+ if has_rules:
181
+ console.print(table)
182
+ else:
183
+ console.print(f"[yellow]No {scope} rules configured.[/yellow]")
184
+
185
+ console.print(f"[dim]Config file: {escape(config_path)}[/dim]")
186
+
187
+
188
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
189
+ project_path = getattr(ui, "project_path", Path.cwd())
190
+ args = trimmed_arg.strip().split()
191
+
192
+ # No args: show all rules
193
+ if not args:
194
+ _render_all_rules(ui.console, project_path)
195
+ ui.console.print()
196
+ ui.console.print("[dim]Usage:[/dim]")
197
+ ui.console.print("[dim] /permissions - Show all rules[/dim]")
198
+ ui.console.print("[dim] /permissions <scope> - Show rules for scope[/dim]")
199
+ ui.console.print("[dim] /permissions add <scope> <type> <rule> - Add a rule[/dim]")
200
+ ui.console.print("[dim] /permissions remove <scope> <type> <rule> - Remove a rule[/dim]")
201
+ ui.console.print("[dim]Scopes: user, project, local[/dim]")
202
+ ui.console.print("[dim]Types: allow, deny[/dim]")
203
+ return True
204
+
205
+ # Parse command
206
+ action = args[0].lower()
207
+ scope_aliases = {
208
+ "user": "user",
209
+ "global": "user",
210
+ "project": "project",
211
+ "workspace": "project",
212
+ "local": "local",
213
+ "private": "local",
214
+ }
215
+
216
+ # Single scope display
217
+ if action in scope_aliases:
218
+ scope = scope_aliases[action]
219
+ _render_scope_rules(ui.console, scope, project_path) # type: ignore
220
+ return True
221
+
222
+ # Add rule
223
+ if action == "add":
224
+ if len(args) < 4:
225
+ ui.console.print("[red]Usage: /permissions add <scope> <type> <rule>[/red]")
226
+ ui.console.print("[dim]Example: /permissions add local allow npm test[/dim]")
227
+ return True
228
+
229
+ scope_arg = args[1].lower()
230
+ if scope_arg not in scope_aliases:
231
+ ui.console.print(f"[red]Unknown scope: {escape(scope_arg)}[/red]")
232
+ ui.console.print("[dim]Available scopes: user, project, local[/dim]")
233
+ return True
234
+ scope: ScopeType = scope_aliases[scope_arg] # type: ignore
235
+
236
+ rule_type_arg = args[2].lower()
237
+ if rule_type_arg not in ("allow", "deny"):
238
+ ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
239
+ ui.console.print("[dim]Available types: allow, deny[/dim]")
240
+ return True
241
+ rule_type: Literal["allow", "deny"] = rule_type_arg # type: ignore
242
+
243
+ rule = " ".join(args[3:])
244
+ if _add_rule(scope, rule_type, rule, project_path):
245
+ ui.console.print(
246
+ Panel(
247
+ f"Added [{'green' if rule_type == 'allow' else 'red'}]{rule_type}[/] rule to {scope}:\n{escape(rule)}",
248
+ title="/permissions",
249
+ )
250
+ )
251
+ else:
252
+ ui.console.print(f"[yellow]Rule already exists in {scope}.[/yellow]")
253
+ return True
254
+
255
+ # Remove rule
256
+ if action in ("remove", "rm", "delete", "del"):
257
+ if len(args) < 4:
258
+ ui.console.print("[red]Usage: /permissions remove <scope> <type> <rule>[/red]")
259
+ ui.console.print("[dim]Example: /permissions remove local allow npm test[/dim]")
260
+ return True
261
+
262
+ scope_arg = args[1].lower()
263
+ if scope_arg not in scope_aliases:
264
+ ui.console.print(f"[red]Unknown scope: {escape(scope_arg)}[/red]")
265
+ ui.console.print("[dim]Available scopes: user, project, local[/dim]")
266
+ return True
267
+ scope = scope_aliases[scope_arg] # type: ignore
268
+
269
+ rule_type_arg = args[2].lower()
270
+ if rule_type_arg not in ("allow", "deny"):
271
+ ui.console.print(f"[red]Unknown rule type: {escape(rule_type_arg)}[/red]")
272
+ ui.console.print("[dim]Available types: allow, deny[/dim]")
273
+ return True
274
+ rule_type = rule_type_arg # type: ignore
275
+
276
+ rule = " ".join(args[3:])
277
+ if _remove_rule(scope, rule_type, rule, project_path):
278
+ ui.console.print(
279
+ Panel(
280
+ f"Removed [{'green' if rule_type == 'allow' else 'red'}]{rule_type}[/] rule from {scope}:\n{escape(rule)}",
281
+ title="/permissions",
282
+ )
283
+ )
284
+ else:
285
+ ui.console.print(f"[yellow]Rule not found in {scope}.[/yellow]")
286
+ return True
287
+
288
+ # Unknown command
289
+ ui.console.print(f"[red]Unknown action: {escape(action)}[/red]")
290
+ ui.console.print("[dim]Available actions: add, remove, or a scope name[/dim]")
291
+ return True
292
+
293
+
294
+ command = SlashCommand(
295
+ name="permissions",
296
+ description="Manage permission rules for tools",
297
+ handler=_handle,
298
+ aliases=(),
299
+ )
300
+
301
+
302
+ __all__ = ["command"]
@@ -0,0 +1,98 @@
1
+ from typing import Any
2
+ from datetime import datetime
3
+ from typing import Optional
4
+
5
+ from rich.markup import escape
6
+
7
+ from ripperdoc.utils.session_history import (
8
+ SessionSummary,
9
+ list_session_summaries,
10
+ load_session_messages,
11
+ )
12
+
13
+ from .base import SlashCommand
14
+
15
+
16
+ def _format_time(dt: datetime) -> str:
17
+ return dt.strftime("%Y-%m-%d %H:%M")
18
+
19
+
20
+ def _choose_session(ui: Any, arg: str) -> Optional[SessionSummary]:
21
+ sessions = list_session_summaries(ui.project_path)
22
+ if not sessions:
23
+ ui.console.print("[yellow]No saved sessions found for this project.[/yellow]")
24
+ return None
25
+
26
+ # If a numeric arg is provided, try to resolve immediately.
27
+ if arg.strip():
28
+ if arg.isdigit():
29
+ idx = int(arg)
30
+ if 0 <= idx < len(sessions):
31
+ return sessions[idx]
32
+ ui.console.print(
33
+ f"[red]Invalid session index {escape(str(idx))}. "
34
+ f"Choose 0-{len(sessions) - 1}.[/red]"
35
+ )
36
+ else:
37
+ # Treat arg as session id if it matches.
38
+ match = next((s for s in sessions if s.session_id.startswith(arg.strip())), None)
39
+ if match:
40
+ return match
41
+ ui.console.print(f"[red]No session found matching '{escape(arg)}'.[/red]")
42
+ return None
43
+
44
+ ui.console.print("\n[bold]Saved sessions:[/bold]")
45
+ for idx, summary in enumerate(sessions):
46
+ ui.console.print(
47
+ f" [{idx}] {summary.session_id} "
48
+ f"({summary.message_count} messages, "
49
+ f"{_format_time(summary.created_at)} → {_format_time(summary.updated_at)}) "
50
+ f"{escape(summary.first_prompt)}",
51
+ markup=False,
52
+ )
53
+
54
+ choice_text = ui.console.input("\nSelect a session index (Enter to cancel): ").strip()
55
+ if not choice_text:
56
+ return None
57
+ if not choice_text.isdigit():
58
+ ui.console.print("[red]Please enter a number.[/red]")
59
+ return None
60
+
61
+ idx = int(choice_text)
62
+ if idx < 0 or idx >= len(sessions):
63
+ ui.console.print(
64
+ f"[red]Invalid session index {escape(str(idx))}. Choose 0-{len(sessions) - 1}.[/red]"
65
+ )
66
+ return None
67
+ return sessions[idx]
68
+
69
+
70
+ def _handle(ui: Any, arg: str) -> bool:
71
+ summary = _choose_session(ui, arg)
72
+ if not summary:
73
+ return True
74
+
75
+ messages = load_session_messages(ui.project_path, summary.session_id)
76
+ if not messages:
77
+ ui.console.print("[red]Failed to load messages for the selected session.[/red]")
78
+ return True
79
+
80
+ ui.conversation_messages = messages
81
+ ui._saved_conversation = None
82
+ ui._set_session(summary.session_id)
83
+ ui.replay_conversation(messages)
84
+ ui.console.print(
85
+ f"[green]✓ Resumed session {escape(summary.session_id)} "
86
+ f"with {len(messages)} messages.[/green]"
87
+ )
88
+ return True
89
+
90
+
91
+ command = SlashCommand(
92
+ name="resume",
93
+ description="Resume a previous session conversation",
94
+ handler=_handle,
95
+ )
96
+
97
+
98
+ __all__ = ["command"]
@@ -0,0 +1,167 @@
1
+ from typing import Any
2
+ import os
3
+ from pathlib import Path
4
+ from typing import List, Optional, Tuple
5
+
6
+ from ripperdoc import __version__
7
+ from ripperdoc.cli.ui.helpers import get_profile_for_pointer
8
+ from ripperdoc.core.config import (
9
+ ModelProfile,
10
+ ProviderType,
11
+ api_base_env_candidates,
12
+ api_key_env_candidates,
13
+ get_global_config,
14
+ )
15
+ from ripperdoc.utils.memory import MAX_CONTENT_LENGTH, MemoryFile, collect_all_memory_files
16
+
17
+ from .base import SlashCommand
18
+
19
+
20
+ def _auth_token_display(profile: Optional[ModelProfile]) -> Tuple[str, Optional[str]]:
21
+ """Return a safe auth token summary and the env var used, if any."""
22
+ if not profile:
23
+ return ("Not configured", None)
24
+
25
+ provider_value = (
26
+ profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
27
+ )
28
+
29
+ env_candidates = api_key_env_candidates(profile.provider)
30
+ provider_env = f"{provider_value.upper()}_API_KEY" if provider_value else None
31
+ if provider_env and provider_env not in env_candidates:
32
+ env_candidates = [provider_env, *env_candidates]
33
+
34
+ env_var = next((name for name in env_candidates if os.environ.get(name)), None)
35
+ if env_var:
36
+ return (f"{env_var} (env)", env_var)
37
+ if profile.api_key or getattr(profile, "auth_token", None):
38
+ return ("Configured in profile", None)
39
+ return ("Missing", None)
40
+
41
+
42
+ def _api_base_display(profile: Optional[ModelProfile]) -> str:
43
+ """Return a human-readable API base URL line."""
44
+ if not profile:
45
+ return "API base URL: Not configured"
46
+
47
+ label_map = {
48
+ ProviderType.ANTHROPIC: "Anthropic base URL",
49
+ ProviderType.OPENAI_COMPATIBLE: "OpenAI-compatible base URL",
50
+ ProviderType.GEMINI: "Gemini base URL",
51
+ }
52
+ label = label_map.get(profile.provider, "API base URL")
53
+ provider_value = (
54
+ profile.provider.value if hasattr(profile.provider, "value") else str(profile.provider)
55
+ )
56
+
57
+ env_candidates = api_base_env_candidates(profile.provider)
58
+ provider_env = f"{provider_value.upper()}_BASE_URL" if provider_value else None
59
+ if provider_env and provider_env not in env_candidates:
60
+ env_candidates = [provider_env, *env_candidates]
61
+
62
+ base_url = profile.api_base
63
+ if not base_url:
64
+ base_url = next(
65
+ (os.environ.get(name) for name in env_candidates if os.environ.get(name)), None
66
+ )
67
+
68
+ return f"{label}: {base_url or 'default'}"
69
+
70
+
71
+ def _memory_status_lines(memory_files: List[MemoryFile]) -> List[str]:
72
+ """Summarize AGENTS memory files and any issues."""
73
+ if not memory_files:
74
+ return ["None detected"]
75
+
76
+ lines = [f"{len(memory_files)} file(s) loaded"]
77
+ oversized = [memory for memory in memory_files if len(memory.content) > MAX_CONTENT_LENGTH]
78
+ for memory in oversized:
79
+ lines.append(f"! {memory.path} ({len(memory.content)} chars > {MAX_CONTENT_LENGTH})")
80
+ return lines
81
+
82
+
83
+ def _setting_sources_summary(
84
+ config: Any,
85
+ profile: Optional[ModelProfile],
86
+ memory_files: List[MemoryFile],
87
+ auth_env_var: Optional[str],
88
+ safe_mode: bool,
89
+ verbose: bool,
90
+ project_path: Path,
91
+ ) -> str:
92
+ """Describe where settings for this session were sourced from."""
93
+ sources: list[str] = []
94
+ if (Path.home() / ".ripperdoc.json").exists():
95
+ sources.append("User settings")
96
+
97
+ project_config_path = project_path / ".ripperdoc" / "config.json"
98
+ if project_config_path.exists():
99
+ sources.append("Project settings")
100
+
101
+ if any(memory.type == "Local" for memory in memory_files):
102
+ sources.append("Local memory")
103
+ if any(memory.type == "Project" for memory in memory_files):
104
+ sources.append("Project memory")
105
+ if auth_env_var:
106
+ sources.append("Environment variables")
107
+
108
+ config_safe_mode = getattr(config, "safe_mode", True)
109
+ config_verbose = getattr(config, "verbose", False)
110
+ if safe_mode != config_safe_mode or verbose != config_verbose:
111
+ sources.append("Command line arguments")
112
+
113
+ if profile and profile.api_key and not auth_env_var:
114
+ sources.append("Profile configuration")
115
+
116
+ if not sources:
117
+ sources.append("Defaults")
118
+
119
+ unique_sources = list(dict.fromkeys(sources))
120
+ return ", ".join(unique_sources)
121
+
122
+
123
+ def _handle(ui: Any, _: str) -> bool:
124
+ config = get_global_config()
125
+ profile = get_profile_for_pointer("main")
126
+ memory_files = collect_all_memory_files()
127
+
128
+ auth_summary, auth_env_var = _auth_token_display(profile)
129
+ api_base_summary = _api_base_display(profile)
130
+ memory_lines = _memory_status_lines(memory_files)
131
+ setting_sources = _setting_sources_summary(
132
+ config,
133
+ profile,
134
+ memory_files,
135
+ auth_env_var,
136
+ ui.safe_mode,
137
+ ui.verbose,
138
+ ui.project_path,
139
+ )
140
+
141
+ model_label = profile.model if profile else "Not configured"
142
+
143
+ ui.console.print()
144
+ ui.console.rule()
145
+ ui.console.print(" Status:\n")
146
+ ui.console.print(f" Version: {__version__}")
147
+ ui.console.print(f" Session ID: {ui.session_id}")
148
+ ui.console.print(f" cwd: {Path.cwd()}")
149
+ ui.console.print(f" Auth token: {auth_summary}")
150
+ ui.console.print(f" {api_base_summary}")
151
+ ui.console.print()
152
+ ui.console.print(f" Model: {model_label}")
153
+ ui.console.print(" Memory:")
154
+ for line in memory_lines:
155
+ ui.console.print(f" {line}")
156
+ ui.console.print(f" Setting sources: {setting_sources}")
157
+ return True
158
+
159
+
160
+ command = SlashCommand(
161
+ name="status",
162
+ description="Show session status",
163
+ handler=_handle,
164
+ )
165
+
166
+
167
+ __all__ = ["command"]