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,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"]