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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
"""Slash command to diagnose common setup issues."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from pathlib import Path
|
|
7
|
+
from typing import Any, Callable, List, Optional, Tuple
|
|
8
|
+
|
|
9
|
+
from rich.markup import escape
|
|
10
|
+
from rich.panel import Panel
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from ripperdoc.core.config import (
|
|
14
|
+
ProviderType,
|
|
15
|
+
api_key_env_candidates,
|
|
16
|
+
get_global_config,
|
|
17
|
+
get_project_config,
|
|
18
|
+
)
|
|
19
|
+
from ripperdoc.cli.ui.helpers import get_profile_for_pointer
|
|
20
|
+
from ripperdoc.utils.log import get_logger
|
|
21
|
+
from ripperdoc.utils.mcp import load_mcp_servers_async
|
|
22
|
+
from ripperdoc.utils.sandbox_utils import is_sandbox_available
|
|
23
|
+
|
|
24
|
+
from .base import SlashCommand
|
|
25
|
+
|
|
26
|
+
logger = get_logger()
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _status_row(label: str, status: str, detail: str = "") -> Tuple[str, str, str]:
|
|
30
|
+
"""Build a (label, status, detail) tuple with icon."""
|
|
31
|
+
icons = {
|
|
32
|
+
"ok": "[green]✓[/green]",
|
|
33
|
+
"warn": "[yellow]![/yellow]",
|
|
34
|
+
"error": "[red]×[/red]",
|
|
35
|
+
}
|
|
36
|
+
icon = icons.get(status, "[yellow]?[/yellow]")
|
|
37
|
+
return (label, icon, detail)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
def _api_key_status(provider: ProviderType, profile_key: Optional[str]) -> Tuple[str, str]:
|
|
41
|
+
"""Check API key presence and source."""
|
|
42
|
+
import os
|
|
43
|
+
|
|
44
|
+
for env_var in api_key_env_candidates(provider):
|
|
45
|
+
if os.environ.get(env_var):
|
|
46
|
+
masked = os.environ[env_var]
|
|
47
|
+
masked = masked[:4] + "…" if len(masked) > 4 else "set"
|
|
48
|
+
return ("ok", f"Found in ${env_var} ({masked})")
|
|
49
|
+
|
|
50
|
+
if profile_key:
|
|
51
|
+
return ("ok", "Stored in config profile")
|
|
52
|
+
|
|
53
|
+
return ("error", "Missing API key for active provider; set $ENV or edit config")
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _model_status(project_path: Path) -> List[Tuple[str, str, str]]:
|
|
57
|
+
config = get_global_config()
|
|
58
|
+
pointer = getattr(config.model_pointers, "main", "default")
|
|
59
|
+
profile = get_profile_for_pointer("main")
|
|
60
|
+
rows: List[Tuple[str, str, str]] = []
|
|
61
|
+
|
|
62
|
+
if not profile:
|
|
63
|
+
rows.append(
|
|
64
|
+
_status_row("Model profile", "error", "No profile configured for pointer 'main'")
|
|
65
|
+
)
|
|
66
|
+
return rows
|
|
67
|
+
|
|
68
|
+
if pointer not in config.model_profiles:
|
|
69
|
+
rows.append(
|
|
70
|
+
_status_row(
|
|
71
|
+
"Model pointer",
|
|
72
|
+
"warn",
|
|
73
|
+
f"Pointer 'main' targets '{pointer}' which is missing; using fallback.",
|
|
74
|
+
)
|
|
75
|
+
)
|
|
76
|
+
rows.append(
|
|
77
|
+
_status_row(
|
|
78
|
+
"Model",
|
|
79
|
+
"ok",
|
|
80
|
+
f"{profile.model} ({profile.provider.value})",
|
|
81
|
+
)
|
|
82
|
+
)
|
|
83
|
+
|
|
84
|
+
key_status, key_detail = _api_key_status(profile.provider, profile.api_key)
|
|
85
|
+
rows.append(_status_row("API key", key_status, key_detail))
|
|
86
|
+
return rows
|
|
87
|
+
|
|
88
|
+
|
|
89
|
+
def _onboarding_status() -> Tuple[str, str, str]:
|
|
90
|
+
config = get_global_config()
|
|
91
|
+
if config.has_completed_onboarding:
|
|
92
|
+
return _status_row(
|
|
93
|
+
"Onboarding",
|
|
94
|
+
"ok",
|
|
95
|
+
f"Completed (version {str(config.last_onboarding_version or 'unknown')})",
|
|
96
|
+
)
|
|
97
|
+
return _status_row(
|
|
98
|
+
"Onboarding",
|
|
99
|
+
"warn",
|
|
100
|
+
"Not completed; run the CLI without flags to configure provider/model.",
|
|
101
|
+
)
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
def _sandbox_status() -> Tuple[str, str, str]:
|
|
105
|
+
available = is_sandbox_available()
|
|
106
|
+
if available:
|
|
107
|
+
return _status_row("Sandbox", "ok", "'srt' runtime is available")
|
|
108
|
+
return _status_row("Sandbox", "warn", "Sandbox runtime not detected; commands run normally")
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
def _mcp_status(
|
|
112
|
+
project_path: Path, runner: Optional[Callable[[Any], Any]] = None
|
|
113
|
+
) -> Tuple[List[Tuple[str, str, str]], List[str]]:
|
|
114
|
+
"""Return MCP status rows and errors."""
|
|
115
|
+
rows: List[Tuple[str, str, str]] = []
|
|
116
|
+
errors: List[str] = []
|
|
117
|
+
|
|
118
|
+
async def _load() -> List[Any]:
|
|
119
|
+
return await load_mcp_servers_async(project_path)
|
|
120
|
+
|
|
121
|
+
try:
|
|
122
|
+
if runner is None:
|
|
123
|
+
import asyncio
|
|
124
|
+
|
|
125
|
+
servers = asyncio.run(_load())
|
|
126
|
+
else:
|
|
127
|
+
servers = runner(_load())
|
|
128
|
+
except (OSError, RuntimeError, ConnectionError, ValueError, TypeError) as exc: # pragma: no cover - defensive
|
|
129
|
+
logger.warning(
|
|
130
|
+
"[doctor] Failed to load MCP servers: %s: %s",
|
|
131
|
+
type(exc).__name__, exc,
|
|
132
|
+
exc_info=exc,
|
|
133
|
+
)
|
|
134
|
+
rows.append(_status_row("MCP", "error", f"Failed to load MCP config: {exc}"))
|
|
135
|
+
return rows, errors
|
|
136
|
+
|
|
137
|
+
if not servers:
|
|
138
|
+
rows.append(_status_row("MCP", "warn", "No MCP servers configured (.mcp.json)"))
|
|
139
|
+
return rows, errors
|
|
140
|
+
|
|
141
|
+
failing = [s for s in servers if getattr(s, "error", None)]
|
|
142
|
+
rows.append(
|
|
143
|
+
_status_row(
|
|
144
|
+
"MCP",
|
|
145
|
+
"ok" if not failing else "warn",
|
|
146
|
+
f"{len(servers)} configured; {len(failing)} with errors",
|
|
147
|
+
)
|
|
148
|
+
)
|
|
149
|
+
for server in failing[:5]:
|
|
150
|
+
errors.append(f"{server.name}: {server.error}")
|
|
151
|
+
if len(failing) > 5:
|
|
152
|
+
errors.append(f"... {len(failing) - 5} more")
|
|
153
|
+
return rows, errors
|
|
154
|
+
|
|
155
|
+
|
|
156
|
+
def _project_status(project_path: Path) -> Tuple[str, str, str]:
|
|
157
|
+
try:
|
|
158
|
+
config = get_project_config(project_path)
|
|
159
|
+
# Access a field to ensure model parsing does not throw.
|
|
160
|
+
_ = len(config.allowed_tools)
|
|
161
|
+
return _status_row(
|
|
162
|
+
"Project config", "ok", f".ripperdoc/config.json loaded for {project_path}"
|
|
163
|
+
)
|
|
164
|
+
except (OSError, IOError, json.JSONDecodeError, ValueError, TypeError) as exc: # pragma: no cover - defensive
|
|
165
|
+
logger.warning(
|
|
166
|
+
"[doctor] Failed to load project config: %s: %s",
|
|
167
|
+
type(exc).__name__, exc,
|
|
168
|
+
exc_info=exc,
|
|
169
|
+
)
|
|
170
|
+
return _status_row(
|
|
171
|
+
"Project config", "warn", f"Could not read .ripperdoc/config.json: {exc}"
|
|
172
|
+
)
|
|
173
|
+
|
|
174
|
+
|
|
175
|
+
def _render_table(console: Any, rows: List[Tuple[str, str, str]]) -> None:
|
|
176
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
177
|
+
table.add_column("Check")
|
|
178
|
+
table.add_column("")
|
|
179
|
+
table.add_column("Details")
|
|
180
|
+
for label, status, detail in rows:
|
|
181
|
+
table.add_row(label, status, escape(detail) if detail else "")
|
|
182
|
+
console.print(table)
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
186
|
+
project_path = getattr(ui, "project_path", Path.cwd())
|
|
187
|
+
results: List[Tuple[str, str, str]] = []
|
|
188
|
+
|
|
189
|
+
results.append(_onboarding_status())
|
|
190
|
+
results.extend(_model_status(project_path))
|
|
191
|
+
project_row = _project_status(project_path)
|
|
192
|
+
results.append(project_row)
|
|
193
|
+
|
|
194
|
+
runner = getattr(ui, "run_async", None)
|
|
195
|
+
mcp_rows, mcp_errors = _mcp_status(project_path, runner=runner)
|
|
196
|
+
results.extend(mcp_rows)
|
|
197
|
+
results.append(_sandbox_status())
|
|
198
|
+
|
|
199
|
+
ui.console.print(Panel("Environment diagnostics", title="/doctor", border_style="cyan"))
|
|
200
|
+
_render_table(ui.console, results)
|
|
201
|
+
|
|
202
|
+
if mcp_errors:
|
|
203
|
+
ui.console.print("\n[bold]MCP issues:[/bold]")
|
|
204
|
+
for err in mcp_errors:
|
|
205
|
+
ui.console.print(f" • {escape(err)}")
|
|
206
|
+
|
|
207
|
+
ui.console.print(
|
|
208
|
+
"\n[dim]If a check is failing, run `ripperdoc` without flags "
|
|
209
|
+
"to rerun onboarding or update ~/.ripperdoc.json[/dim]"
|
|
210
|
+
)
|
|
211
|
+
return True
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
command = SlashCommand(
|
|
215
|
+
name="doctor",
|
|
216
|
+
description="Diagnose model config, API keys, MCP, and sandbox support",
|
|
217
|
+
handler=_handle,
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .base import SlashCommand
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
6
|
+
ui.console.print("[yellow]Goodbye![/yellow]")
|
|
7
|
+
ui._should_exit = True
|
|
8
|
+
return True
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
command = SlashCommand(
|
|
12
|
+
name="exit",
|
|
13
|
+
description="Exit Ripperdoc",
|
|
14
|
+
handler=_handle,
|
|
15
|
+
aliases=(),
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
from typing import Any
|
|
2
|
+
from .base import SlashCommand
|
|
3
|
+
|
|
4
|
+
|
|
5
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
6
|
+
ui.console.print("\n[bold]Available Slash Commands:[/bold]")
|
|
7
|
+
for cmd in ui.command_list:
|
|
8
|
+
alias_text = f" (aliases: {', '.join(cmd.aliases)})" if cmd.aliases else ""
|
|
9
|
+
ui.console.print(f" /{cmd.name:<8} - {cmd.description}{alias_text}")
|
|
10
|
+
return True
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
command = SlashCommand(
|
|
14
|
+
name="help",
|
|
15
|
+
description="Show available slash commands",
|
|
16
|
+
handler=_handle,
|
|
17
|
+
)
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
__all__ = ["command"]
|
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
from rich.markup import escape
|
|
2
|
+
|
|
3
|
+
from ripperdoc.utils.mcp import load_mcp_servers_async
|
|
4
|
+
|
|
5
|
+
from typing import Any
|
|
6
|
+
from .base import SlashCommand
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def _run_in_ui(ui: Any, coro: Any) -> Any:
|
|
10
|
+
runner = getattr(ui, "run_async", None)
|
|
11
|
+
if callable(runner):
|
|
12
|
+
return runner(coro)
|
|
13
|
+
# Fallback for non-UI contexts.
|
|
14
|
+
import asyncio
|
|
15
|
+
|
|
16
|
+
return asyncio.run(coro)
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
20
|
+
async def _load() -> list:
|
|
21
|
+
return await load_mcp_servers_async(ui.project_path)
|
|
22
|
+
|
|
23
|
+
servers = _run_in_ui(ui, _load())
|
|
24
|
+
if not servers:
|
|
25
|
+
ui.console.print(
|
|
26
|
+
"[yellow]No MCP servers configured. Add servers to ~/.ripperdoc/mcp.json, ~/.mcp.json, or a project .mcp.json file.[/yellow]"
|
|
27
|
+
)
|
|
28
|
+
return True
|
|
29
|
+
|
|
30
|
+
ui.console.print("\n[bold]MCP servers[/bold]")
|
|
31
|
+
for server in servers:
|
|
32
|
+
status = server.status or "unknown"
|
|
33
|
+
url_part = f" ({server.url})" if server.url else ""
|
|
34
|
+
ui.console.print(f"- {server.name}{url_part} — {status}", markup=False)
|
|
35
|
+
if server.command:
|
|
36
|
+
cmd_line = " ".join([server.command, *server.args]) if server.args else server.command
|
|
37
|
+
ui.console.print(f" Command: {cmd_line}", markup=False)
|
|
38
|
+
if server.description:
|
|
39
|
+
ui.console.print(f" {server.description}", markup=False)
|
|
40
|
+
if server.error:
|
|
41
|
+
ui.console.print(f" [red]Error:[/red] {escape(str(server.error))}")
|
|
42
|
+
if server.instructions:
|
|
43
|
+
snippet = server.instructions.strip()
|
|
44
|
+
if len(snippet) > 160:
|
|
45
|
+
snippet = snippet[:157] + "..."
|
|
46
|
+
ui.console.print(f" Instructions: {snippet}", markup=False)
|
|
47
|
+
if server.tools:
|
|
48
|
+
ui.console.print(" Tools:")
|
|
49
|
+
for tool in server.tools:
|
|
50
|
+
desc = f" — {tool.description}" if tool.description else ""
|
|
51
|
+
ui.console.print(f" • {tool.name}{desc}", markup=False)
|
|
52
|
+
else:
|
|
53
|
+
ui.console.print(" Tools: none discovered")
|
|
54
|
+
if server.resources:
|
|
55
|
+
ui.console.print(
|
|
56
|
+
" Resources: " + ", ".join(res.uri for res in server.resources), markup=False
|
|
57
|
+
)
|
|
58
|
+
elif not server.tools:
|
|
59
|
+
ui.console.print(" Resources: none")
|
|
60
|
+
return True
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
command = SlashCommand(
|
|
64
|
+
name="mcp",
|
|
65
|
+
description="Show configured MCP servers and their tools",
|
|
66
|
+
handler=_handle,
|
|
67
|
+
)
|
|
68
|
+
|
|
69
|
+
|
|
70
|
+
__all__ = ["command"]
|
|
@@ -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 (ValueError, OSError):
|
|
30
|
+
pass
|
|
31
|
+
|
|
32
|
+
home = Path.home()
|
|
33
|
+
try:
|
|
34
|
+
rel_home = path.resolve().relative_to(home)
|
|
35
|
+
return f"~/{rel_home}"
|
|
36
|
+
except (ValueError, OSError):
|
|
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 (OSError, IOError):
|
|
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 (OSError, subprocess.SubprocessError) 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=(),
|
|
199
|
+
)
|
|
200
|
+
|
|
201
|
+
|
|
202
|
+
__all__ = ["command"]
|