ripperdoc 0.1.0__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 (81) hide show
  1. ripperdoc/__init__.py +3 -0
  2. ripperdoc/__main__.py +25 -0
  3. ripperdoc/cli/__init__.py +1 -0
  4. ripperdoc/cli/cli.py +317 -0
  5. ripperdoc/cli/commands/__init__.py +76 -0
  6. ripperdoc/cli/commands/agents_cmd.py +234 -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 +19 -0
  10. ripperdoc/cli/commands/config_cmd.py +31 -0
  11. ripperdoc/cli/commands/context_cmd.py +114 -0
  12. ripperdoc/cli/commands/cost_cmd.py +77 -0
  13. ripperdoc/cli/commands/exit_cmd.py +19 -0
  14. ripperdoc/cli/commands/help_cmd.py +20 -0
  15. ripperdoc/cli/commands/mcp_cmd.py +65 -0
  16. ripperdoc/cli/commands/models_cmd.py +327 -0
  17. ripperdoc/cli/commands/resume_cmd.py +97 -0
  18. ripperdoc/cli/commands/status_cmd.py +167 -0
  19. ripperdoc/cli/commands/tasks_cmd.py +240 -0
  20. ripperdoc/cli/commands/todos_cmd.py +69 -0
  21. ripperdoc/cli/commands/tools_cmd.py +19 -0
  22. ripperdoc/cli/ui/__init__.py +1 -0
  23. ripperdoc/cli/ui/context_display.py +297 -0
  24. ripperdoc/cli/ui/helpers.py +22 -0
  25. ripperdoc/cli/ui/rich_ui.py +1010 -0
  26. ripperdoc/cli/ui/spinner.py +50 -0
  27. ripperdoc/core/__init__.py +1 -0
  28. ripperdoc/core/agents.py +306 -0
  29. ripperdoc/core/commands.py +33 -0
  30. ripperdoc/core/config.py +382 -0
  31. ripperdoc/core/default_tools.py +57 -0
  32. ripperdoc/core/permissions.py +227 -0
  33. ripperdoc/core/query.py +682 -0
  34. ripperdoc/core/system_prompt.py +418 -0
  35. ripperdoc/core/tool.py +214 -0
  36. ripperdoc/sdk/__init__.py +9 -0
  37. ripperdoc/sdk/client.py +309 -0
  38. ripperdoc/tools/__init__.py +1 -0
  39. ripperdoc/tools/background_shell.py +291 -0
  40. ripperdoc/tools/bash_output_tool.py +98 -0
  41. ripperdoc/tools/bash_tool.py +822 -0
  42. ripperdoc/tools/file_edit_tool.py +281 -0
  43. ripperdoc/tools/file_read_tool.py +168 -0
  44. ripperdoc/tools/file_write_tool.py +141 -0
  45. ripperdoc/tools/glob_tool.py +134 -0
  46. ripperdoc/tools/grep_tool.py +232 -0
  47. ripperdoc/tools/kill_bash_tool.py +136 -0
  48. ripperdoc/tools/ls_tool.py +298 -0
  49. ripperdoc/tools/mcp_tools.py +804 -0
  50. ripperdoc/tools/multi_edit_tool.py +393 -0
  51. ripperdoc/tools/notebook_edit_tool.py +325 -0
  52. ripperdoc/tools/task_tool.py +282 -0
  53. ripperdoc/tools/todo_tool.py +362 -0
  54. ripperdoc/tools/tool_search_tool.py +366 -0
  55. ripperdoc/utils/__init__.py +1 -0
  56. ripperdoc/utils/bash_constants.py +51 -0
  57. ripperdoc/utils/bash_output_utils.py +43 -0
  58. ripperdoc/utils/exit_code_handlers.py +241 -0
  59. ripperdoc/utils/log.py +76 -0
  60. ripperdoc/utils/mcp.py +427 -0
  61. ripperdoc/utils/memory.py +239 -0
  62. ripperdoc/utils/message_compaction.py +640 -0
  63. ripperdoc/utils/messages.py +399 -0
  64. ripperdoc/utils/output_utils.py +233 -0
  65. ripperdoc/utils/path_utils.py +46 -0
  66. ripperdoc/utils/permissions/__init__.py +21 -0
  67. ripperdoc/utils/permissions/path_validation_utils.py +165 -0
  68. ripperdoc/utils/permissions/shell_command_validation.py +74 -0
  69. ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
  70. ripperdoc/utils/safe_get_cwd.py +24 -0
  71. ripperdoc/utils/sandbox_utils.py +38 -0
  72. ripperdoc/utils/session_history.py +223 -0
  73. ripperdoc/utils/session_usage.py +110 -0
  74. ripperdoc/utils/shell_token_utils.py +95 -0
  75. ripperdoc/utils/todo.py +199 -0
  76. ripperdoc-0.1.0.dist-info/METADATA +178 -0
  77. ripperdoc-0.1.0.dist-info/RECORD +81 -0
  78. ripperdoc-0.1.0.dist-info/WHEEL +5 -0
  79. ripperdoc-0.1.0.dist-info/entry_points.txt +3 -0
  80. ripperdoc-0.1.0.dist-info/licenses/LICENSE +53 -0
  81. ripperdoc-0.1.0.dist-info/top_level.txt +1 -0
ripperdoc/__init__.py ADDED
@@ -0,0 +1,3 @@
1
+ """Ripperdoc - AI-powered coding agent."""
2
+
3
+ __version__ = "0.1.0"
ripperdoc/__main__.py ADDED
@@ -0,0 +1,25 @@
1
+ """
2
+ Ripperdoc - AI-Powered Coding Agent
3
+
4
+ A Python implementation of an AI coding assistant.
5
+
6
+ Features:
7
+ - Execute bash commands
8
+ - Read and edit files
9
+ - Search code with glob and grep
10
+ - AI-powered code assistance
11
+ - Multi-model support
12
+
13
+ Quick Start:
14
+ pip install -e .
15
+ ripperdoc -p "your prompt here"
16
+
17
+ For more information:
18
+ - README.md: Project overview
19
+ - QUICKSTART.md: Quick start guide
20
+ - DEVELOPMENT.md: Development guide
21
+ """
22
+
23
+ from ripperdoc import __version__
24
+
25
+ __all__ = ["__version__"]
@@ -0,0 +1 @@
1
+ """CLI interface for Ripperdoc."""
ripperdoc/cli/cli.py ADDED
@@ -0,0 +1,317 @@
1
+ """Main CLI entry point for Ripperdoc.
2
+
3
+ This module provides the command-line interface for the Ripperdoc agent.
4
+ """
5
+
6
+ import asyncio
7
+ import click
8
+ import sys
9
+ from pathlib import Path
10
+ from typing import Optional
11
+
12
+ from ripperdoc import __version__
13
+ from ripperdoc.core.config import (
14
+ get_global_config,
15
+ save_global_config,
16
+ get_project_config,
17
+ ModelProfile,
18
+ ProviderType,
19
+ )
20
+ from ripperdoc.core.default_tools import get_default_tools
21
+ from ripperdoc.core.query import query, QueryContext
22
+ from ripperdoc.core.system_prompt import build_system_prompt
23
+ from ripperdoc.utils.messages import create_user_message
24
+ from ripperdoc.utils.memory import build_memory_instructions
25
+ from ripperdoc.core.permissions import make_permission_checker
26
+ from ripperdoc.utils.mcp import (
27
+ load_mcp_servers_async,
28
+ format_mcp_instructions,
29
+ shutdown_mcp_runtime,
30
+ )
31
+ from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
32
+
33
+ from rich.console import Console
34
+ from rich.markdown import Markdown
35
+ from rich.panel import Panel
36
+ from rich.markup import escape
37
+
38
+ console = Console()
39
+
40
+
41
+ async def run_query(
42
+ prompt: str, tools: list, safe_mode: bool = False, verbose: bool = False
43
+ ) -> None:
44
+ """Run a single query and print the response."""
45
+
46
+ project_path = Path.cwd()
47
+ can_use_tool = make_permission_checker(project_path, safe_mode) if safe_mode else None
48
+
49
+ # Create initial user message
50
+ messages = [create_user_message(prompt)]
51
+
52
+ # Create query context
53
+ query_context = QueryContext(tools=tools, safe_mode=safe_mode, verbose=verbose)
54
+
55
+ try:
56
+ context = {}
57
+ # System prompt
58
+ servers = await load_mcp_servers_async(Path.cwd())
59
+ dynamic_tools = await load_dynamic_mcp_tools_async(Path.cwd())
60
+ if dynamic_tools:
61
+ tools = merge_tools_with_dynamic(tools, dynamic_tools)
62
+ query_context.tools = tools
63
+ mcp_instructions = format_mcp_instructions(servers)
64
+ base_system_prompt = build_system_prompt(
65
+ tools,
66
+ prompt,
67
+ context,
68
+ mcp_instructions=mcp_instructions,
69
+ )
70
+ memory_instructions = build_memory_instructions()
71
+ system_prompt = (
72
+ f"{base_system_prompt}\n\n{memory_instructions}"
73
+ if memory_instructions
74
+ else base_system_prompt
75
+ )
76
+
77
+ # Run the query
78
+ try:
79
+ async for message in query(
80
+ messages, system_prompt, context, query_context, can_use_tool
81
+ ):
82
+ if message.type == "assistant":
83
+ # Print assistant message
84
+ if isinstance(message.message.content, str):
85
+ console.print(
86
+ Panel(
87
+ Markdown(message.message.content),
88
+ title="Ripperdoc",
89
+ border_style="cyan",
90
+ )
91
+ )
92
+ else:
93
+ # Handle structured content
94
+ for block in message.message.content:
95
+ if isinstance(block, dict):
96
+ if block.get("type") == "text":
97
+ console.print(
98
+ Panel(
99
+ Markdown(block["text"]),
100
+ title="Ripperdoc",
101
+ border_style="cyan",
102
+ )
103
+ )
104
+ else:
105
+ if hasattr(block, "type") and block.type == "text":
106
+ console.print(
107
+ Panel(
108
+ Markdown(block.text),
109
+ title="Ripperdoc",
110
+ border_style="cyan",
111
+ )
112
+ )
113
+
114
+ elif message.type == "progress":
115
+ # Print progress
116
+ if verbose:
117
+ console.print(f"[dim]Progress: {escape(str(message.content))}[/dim]")
118
+
119
+ # Add message to history
120
+ messages.append(message)
121
+
122
+ except KeyboardInterrupt:
123
+ console.print("\n[yellow]Interrupted by user[/yellow]")
124
+ except Exception as e:
125
+ console.print(f"[red]Error: {escape(str(e))}[/red]")
126
+ if verbose:
127
+ import traceback
128
+
129
+ console.print(traceback.format_exc(), markup=False)
130
+ finally:
131
+ await shutdown_mcp_runtime()
132
+
133
+
134
+ def check_onboarding() -> bool:
135
+ """Check if onboarding is complete and run if needed."""
136
+ config = get_global_config()
137
+
138
+ if config.has_completed_onboarding:
139
+ return True
140
+
141
+ console.print("[bold cyan]Welcome to Ripperdoc![/bold cyan]\n")
142
+ console.print("Let's set up your AI model configuration.\n")
143
+
144
+ # Simple onboarding
145
+ provider_choices = [
146
+ *[p.value for p in ProviderType],
147
+ "openai",
148
+ "deepseek",
149
+ "mistral",
150
+ "kimi",
151
+ "qwen",
152
+ "glm",
153
+ "custom",
154
+ ]
155
+ provider_choice = click.prompt(
156
+ "Choose your model protocol",
157
+ type=click.Choice(provider_choices),
158
+ default=ProviderType.ANTHROPIC.value,
159
+ )
160
+
161
+ api_base = None
162
+ if provider_choice == "custom":
163
+ provider_choice = click.prompt(
164
+ "Protocol family (for API compatibility)",
165
+ type=click.Choice([p.value for p in ProviderType]),
166
+ default=ProviderType.OPENAI_COMPATIBLE.value,
167
+ )
168
+ api_base = click.prompt("API Base URL")
169
+
170
+ api_key = click.prompt("Enter your API key", hide_input=True)
171
+
172
+ provider = ProviderType(provider_choice)
173
+
174
+ # Get model name
175
+ if provider == ProviderType.ANTHROPIC:
176
+ model = click.prompt("Model name", default="claude-3-5-sonnet-20241022")
177
+ elif provider == ProviderType.OPENAI_COMPATIBLE:
178
+ default_model = "gpt-4o-mini"
179
+ if provider_choice == "deepseek":
180
+ default_model = "deepseek-chat"
181
+ api_base = api_base or "https://api.deepseek.com"
182
+ model = click.prompt("Model name", default=default_model)
183
+ if api_base is None:
184
+ api_base = (
185
+ click.prompt("API base URL (optional)", default="", show_default=False) or None
186
+ )
187
+ elif provider == ProviderType.GEMINI:
188
+ console.print(
189
+ "[yellow]Gemini protocol support is not yet available; configuration is saved for "
190
+ "future support.[/yellow]"
191
+ )
192
+ model = click.prompt("Model name", default="gemini-1.5-pro")
193
+ if api_base is None:
194
+ api_base = (
195
+ click.prompt("API base URL (optional)", default="", show_default=False) or None
196
+ )
197
+ else:
198
+ model = click.prompt("Model name")
199
+
200
+ context_window_input = click.prompt(
201
+ "Context window in tokens (optional, press Enter to skip)", default="", show_default=False
202
+ )
203
+ context_window = None
204
+ if context_window_input.strip():
205
+ try:
206
+ context_window = int(context_window_input.strip())
207
+ except ValueError:
208
+ console.print("[yellow]Invalid context window, using auto-detected defaults.[/yellow]")
209
+
210
+ # Create model profile
211
+ config.model_profiles["default"] = ModelProfile(
212
+ provider=provider,
213
+ model=model,
214
+ api_key=api_key,
215
+ api_base=api_base,
216
+ context_window=context_window,
217
+ )
218
+
219
+ config.has_completed_onboarding = True
220
+ config.last_onboarding_version = __version__
221
+
222
+ save_global_config(config)
223
+
224
+ console.print("\n[green]✓ Configuration saved![/green]\n")
225
+
226
+ return True
227
+
228
+
229
+ @click.group(invoke_without_command=True)
230
+ @click.version_option(version=__version__)
231
+ @click.option("--cwd", type=click.Path(exists=True), help="Working directory")
232
+ @click.option(
233
+ "--unsafe",
234
+ is_flag=True,
235
+ help="Disable safe mode (skip permission prompts for tools)",
236
+ )
237
+ @click.option("--verbose", is_flag=True, help="Verbose output")
238
+ @click.option("-p", "--prompt", type=str, help="Direct prompt (non-interactive)")
239
+ @click.pass_context
240
+ def cli(
241
+ ctx: click.Context, cwd: Optional[str], unsafe: bool, verbose: bool, prompt: Optional[str]
242
+ ) -> None:
243
+ """Ripperdoc - AI-powered coding agent"""
244
+
245
+ # Ensure onboarding is complete
246
+ if not check_onboarding():
247
+ sys.exit(1)
248
+
249
+ # Set working directory
250
+ if cwd:
251
+ import os
252
+
253
+ os.chdir(cwd)
254
+
255
+ # Initialize project configuration for the current working directory
256
+ project_path = Path.cwd()
257
+ get_project_config(project_path)
258
+
259
+ safe_mode = not unsafe
260
+
261
+ # If prompt is provided, run directly
262
+ if prompt:
263
+ tools = get_default_tools()
264
+ asyncio.run(run_query(prompt, tools, safe_mode, verbose))
265
+ return
266
+
267
+ # If no command specified, start interactive REPL with Rich interface
268
+ if ctx.invoked_subcommand is None:
269
+ # Use Rich interface by default
270
+ from ripperdoc.cli.ui.rich_ui import main_rich
271
+
272
+ main_rich(safe_mode=safe_mode, verbose=verbose)
273
+ return
274
+
275
+
276
+ @cli.command(name="config")
277
+ def config_cmd() -> None:
278
+ """Show current configuration"""
279
+ config = get_global_config()
280
+
281
+ console.print("\n[bold]Global Configuration[/bold]\n")
282
+ console.print(f"Version: {__version__}")
283
+ console.print(f"Onboarding Complete: {config.has_completed_onboarding}")
284
+ console.print(f"Theme: {config.theme}")
285
+ console.print(f"Verbose: {config.verbose}")
286
+ console.print(f"Safe Mode: {config.safe_mode}\n")
287
+
288
+ if config.model_profiles:
289
+ console.print("[bold]Model Profiles:[/bold]")
290
+ for name, profile in config.model_profiles.items():
291
+ console.print(f" {name}:")
292
+ console.print(f" Provider: {profile.provider}")
293
+ console.print(f" Model: {profile.model}")
294
+ console.print(f" API Key: {'***' if profile.api_key else 'Not set'}")
295
+ console.print()
296
+
297
+
298
+ @cli.command(name="version")
299
+ def version_cmd() -> None:
300
+ """Show version information"""
301
+ console.print(f"Ripperdoc version {__version__}")
302
+
303
+
304
+ def main() -> None:
305
+ """Main entry point."""
306
+ try:
307
+ cli()
308
+ except KeyboardInterrupt:
309
+ console.print("\n[yellow]Interrupted[/yellow]")
310
+ sys.exit(130)
311
+ except Exception as e:
312
+ console.print(f"[red]Fatal error: {escape(str(e))}[/red]")
313
+ sys.exit(1)
314
+
315
+
316
+ if __name__ == "__main__":
317
+ main()
@@ -0,0 +1,76 @@
1
+ """Slash command registry."""
2
+
3
+ from __future__ import annotations
4
+
5
+ from typing import Dict, List
6
+
7
+ from .base import SlashCommand
8
+ from .agents_cmd import command as agents_command
9
+ from .clear_cmd import command as clear_command
10
+ from .compact_cmd import command as compact_command
11
+ from .config_cmd import command as config_command
12
+ from .cost_cmd import command as cost_command
13
+ from .context_cmd import command as context_command
14
+ from .exit_cmd import command as exit_command
15
+ from .help_cmd import command as help_command
16
+ from .mcp_cmd import command as mcp_command
17
+ from .models_cmd import command as models_command
18
+ from .resume_cmd import command as resume_command
19
+ from .tasks_cmd import command as tasks_command
20
+ from .status_cmd import command as status_command
21
+ from .todos_cmd import command as todos_command
22
+ from .tools_cmd import command as tools_command
23
+
24
+
25
+ def _build_registry(commands: List[SlashCommand]) -> Dict[str, SlashCommand]:
26
+ """Map command names and aliases to SlashCommand entries."""
27
+ registry: Dict[str, SlashCommand] = {}
28
+ for cmd in commands:
29
+ registry[cmd.name] = cmd
30
+ for alias in cmd.aliases:
31
+ registry[alias] = cmd
32
+ return registry
33
+
34
+
35
+ ALL_COMMANDS: List[SlashCommand] = [
36
+ help_command,
37
+ clear_command,
38
+ config_command,
39
+ tools_command,
40
+ models_command,
41
+ exit_command,
42
+ status_command,
43
+ tasks_command,
44
+ todos_command,
45
+ mcp_command,
46
+ cost_command,
47
+ context_command,
48
+ compact_command,
49
+ resume_command,
50
+ agents_command,
51
+ ]
52
+
53
+ COMMAND_REGISTRY: Dict[str, SlashCommand] = _build_registry(ALL_COMMANDS)
54
+
55
+
56
+ def list_slash_commands() -> List[SlashCommand]:
57
+ """Return the ordered list of base slash commands (no aliases)."""
58
+ return ALL_COMMANDS
59
+
60
+
61
+ def get_slash_command(name: str) -> SlashCommand | None:
62
+ """Return a command by name or alias."""
63
+ return COMMAND_REGISTRY.get(name)
64
+
65
+
66
+ def slash_command_completions() -> List[tuple[str, SlashCommand]]:
67
+ """Return (name, command) pairs for completion including aliases."""
68
+ return list(COMMAND_REGISTRY.items())
69
+
70
+
71
+ __all__ = [
72
+ "SlashCommand",
73
+ "list_slash_commands",
74
+ "get_slash_command",
75
+ "slash_command_completions",
76
+ ]
@@ -0,0 +1,234 @@
1
+ from rich.markup import escape
2
+
3
+ from ripperdoc.core.agents import (
4
+ AGENT_DIR_NAME,
5
+ AgentLocation,
6
+ delete_agent_definition,
7
+ load_agent_definitions,
8
+ save_agent_definition,
9
+ )
10
+ from ripperdoc.core.config import get_global_config
11
+
12
+ from typing import Any
13
+ from .base import SlashCommand
14
+
15
+
16
+ def _handle(ui: Any, trimmed_arg: str) -> bool:
17
+ console = ui.console
18
+ tokens = trimmed_arg.split()
19
+ subcmd = tokens[0].lower() if tokens else ""
20
+
21
+ def print_agents_usage() -> None:
22
+ console.print("[bold]/agents[/bold] — list configured agents")
23
+ console.print(
24
+ "[bold]/agents create <name> [location] [model][/bold] — create agent (location: user|project, default user)"
25
+ )
26
+ console.print("[bold]/agents edit <name> [location][/bold] — edit an existing agent")
27
+ console.print(
28
+ "[bold]/agents delete <name> [location][/bold] — delete agent (location: user|project, default user)"
29
+ )
30
+ console.print(
31
+ f"[dim]Agent files live in ~/.ripperdoc/{AGENT_DIR_NAME} or ./.ripperdoc/{AGENT_DIR_NAME}[/dim]"
32
+ )
33
+ console.print(
34
+ "[dim]Model can be a profile name or pointer (task/main/etc). Defaults to 'task'.[/dim]"
35
+ )
36
+
37
+ if subcmd in ("help", "-h", "--help"):
38
+ print_agents_usage()
39
+ return True
40
+
41
+ if subcmd in ("create", "add"):
42
+ agent_name = tokens[1] if len(tokens) > 1 else console.input("Agent name: ").strip()
43
+ if not agent_name:
44
+ console.print("[red]Agent name is required.[/red]")
45
+ print_agents_usage()
46
+ return True
47
+
48
+ description = console.input("Description (when to use this agent): ").strip()
49
+ if not description:
50
+ console.print("[red]Description is required.[/red]")
51
+ return True
52
+
53
+ tools_input = console.input("Tools (comma-separated, * for all) [*]: ").strip() or "*"
54
+ tools = [t.strip() for t in tools_input.split(",") if t.strip()] or ["*"]
55
+
56
+ system_prompt = console.input("System prompt (single line, use \\n for newlines): ").strip()
57
+ if not system_prompt:
58
+ console.print("[red]System prompt is required.[/red]")
59
+ print_agents_usage()
60
+ return True
61
+
62
+ location_arg = tokens[2] if len(tokens) > 2 else ""
63
+ model_arg = tokens[3] if len(tokens) > 3 else ""
64
+ if location_arg and location_arg.lower() not in ("user", "project"):
65
+ model_arg, location_arg = location_arg, ""
66
+
67
+ location_raw = (
68
+ location_arg or console.input("Location [user/project, default user]: ").strip()
69
+ ).lower()
70
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
71
+
72
+ config = get_global_config()
73
+ pointer_map = config.model_pointers.model_dump()
74
+ default_model_value = model_arg or pointer_map.get("task", "task")
75
+ model_input = (
76
+ console.input(f"Model profile or pointer [{default_model_value}]: ").strip()
77
+ or default_model_value
78
+ )
79
+ if (
80
+ model_input
81
+ and model_input not in config.model_profiles
82
+ and model_input not in pointer_map
83
+ ):
84
+ console.print(
85
+ "[yellow]Model not found in profiles or pointers; will fall back to main if unavailable.[/yellow]"
86
+ )
87
+
88
+ try:
89
+ path = save_agent_definition(
90
+ agent_type=agent_name,
91
+ description=description,
92
+ tools=tools,
93
+ system_prompt=system_prompt,
94
+ location=location,
95
+ model=model_input,
96
+ )
97
+ console.print(
98
+ f"[green]✓ Agent '{escape(agent_name)}' created at {escape(str(path))}[/green]"
99
+ )
100
+ except Exception as exc:
101
+ console.print(f"[red]Failed to create agent: {escape(str(exc))}[/red]")
102
+ print_agents_usage()
103
+ return True
104
+
105
+ if subcmd in ("delete", "del", "remove"):
106
+ agent_name = (
107
+ tokens[1] if len(tokens) > 1 else console.input("Agent name to delete: ").strip()
108
+ )
109
+ if not agent_name:
110
+ console.print("[red]Agent name is required.[/red]")
111
+ print_agents_usage()
112
+ return True
113
+
114
+ location_raw = (
115
+ tokens[2]
116
+ if len(tokens) > 2
117
+ else console.input("Location to delete from [user/project, default user]: ").strip()
118
+ ).lower()
119
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
120
+ try:
121
+ path = delete_agent_definition(agent_name, location)
122
+ console.print(
123
+ f"[green]✓ Deleted agent '{escape(agent_name)}' at {escape(str(path))}[/green]"
124
+ )
125
+ except FileNotFoundError as exc:
126
+ console.print(f"[yellow]{escape(str(exc))}[/yellow]")
127
+ except Exception as exc:
128
+ console.print(f"[red]Failed to delete agent: {escape(str(exc))}[/red]")
129
+ print_agents_usage()
130
+ return True
131
+
132
+ if subcmd in ("edit", "update"):
133
+ agent_name = tokens[1] if len(tokens) > 1 else console.input("Agent to edit: ").strip()
134
+ if not agent_name:
135
+ console.print("[red]Agent name is required.[/red]")
136
+ print_agents_usage()
137
+ return True
138
+
139
+ agents = load_agent_definitions()
140
+ target_agent = next((a for a in agents.active_agents if a.agent_type == agent_name), None)
141
+ if not target_agent:
142
+ console.print(f"[red]Agent '{escape(agent_name)}' not found.[/red]")
143
+ print_agents_usage()
144
+ return True
145
+ if target_agent.location == AgentLocation.BUILT_IN:
146
+ console.print("[yellow]Built-in agents cannot be edited.[/yellow]")
147
+ return True
148
+
149
+ default_location = target_agent.location
150
+ location_raw = (
151
+ tokens[2]
152
+ if len(tokens) > 2
153
+ else console.input(
154
+ f"Location to save [user/project, default {default_location.value}]: "
155
+ ).strip()
156
+ ).lower()
157
+ location = AgentLocation.PROJECT if location_raw == "project" else AgentLocation.USER
158
+
159
+ description = (
160
+ console.input(f"Description (when to use) [{target_agent.when_to_use}]: ").strip()
161
+ or target_agent.when_to_use
162
+ )
163
+
164
+ tools_default = "*" if "*" in target_agent.tools else ", ".join(target_agent.tools)
165
+ tools_input = (
166
+ console.input(f"Tools (comma-separated, * for all) [{tools_default}]: ").strip()
167
+ or tools_default
168
+ )
169
+ tools = [t.strip() for t in tools_input.split(",") if t.strip()] or ["*"]
170
+
171
+ console.print("[dim]Current system prompt:[/dim]")
172
+ console.print(escape(target_agent.system_prompt or "(empty)"), markup=False)
173
+ system_prompt = (
174
+ console.input(
175
+ "System prompt (single line, use \\n for newlines) " "[Enter to keep current]: "
176
+ ).strip()
177
+ or target_agent.system_prompt
178
+ )
179
+
180
+ config = get_global_config()
181
+ pointer_map = config.model_pointers.model_dump()
182
+ model_default = target_agent.model or pointer_map.get("task", "task")
183
+ model_input = (
184
+ console.input(f"Model profile or pointer [{model_default}]: ").strip() or model_default
185
+ )
186
+
187
+ try:
188
+ path = save_agent_definition(
189
+ agent_type=agent_name,
190
+ description=description,
191
+ tools=tools,
192
+ system_prompt=system_prompt,
193
+ location=location,
194
+ model=model_input,
195
+ overwrite=True,
196
+ )
197
+ console.print(
198
+ f"[green]✓ Agent '{escape(agent_name)}' updated at {escape(str(path))}[/green]"
199
+ )
200
+ except Exception as exc:
201
+ console.print(f"[red]Failed to update agent: {escape(str(exc))}[/red]")
202
+ print_agents_usage()
203
+ return True
204
+
205
+ agents = load_agent_definitions()
206
+ console.print("\n[bold]Agents:[/bold]")
207
+ print_agents_usage()
208
+ if not agents.active_agents:
209
+ console.print(" • None configured")
210
+ for agent in agents.active_agents:
211
+ location = getattr(agent.location, "value", agent.location)
212
+ tools_str = "all tools" if "*" in agent.tools else ", ".join(agent.tools)
213
+ console.print(f" • {escape(agent.agent_type)} ({escape(str(location))})", markup=False)
214
+ console.print(f" {escape(agent.when_to_use)}", markup=False)
215
+ console.print(f" tools: {escape(tools_str)}", markup=False)
216
+ console.print(f" model: {escape(agent.model or 'task (default)')}", markup=False)
217
+ if agents.failed_files:
218
+ console.print("[yellow]Some agent files could not be loaded:[/yellow]")
219
+ for path, error in agents.failed_files:
220
+ console.print(f" - {escape(str(path))}: {escape(str(error))}", markup=False)
221
+ console.print(
222
+ f"[dim]Add agents in ~/.ripperdoc/{AGENT_DIR_NAME} or ./.ripperdoc/{AGENT_DIR_NAME}[/dim]"
223
+ )
224
+ return True
225
+
226
+
227
+ command = SlashCommand(
228
+ name="agents",
229
+ description="Manage subagents: list/create/delete",
230
+ handler=_handle,
231
+ )
232
+
233
+
234
+ __all__ = ["command"]
@@ -0,0 +1,19 @@
1
+ """Shared types for slash command handlers."""
2
+
3
+ from dataclasses import dataclass
4
+ from typing import Any, Callable, Tuple
5
+
6
+ Handler = Callable[[Any, str], bool]
7
+
8
+
9
+ @dataclass(frozen=True)
10
+ class SlashCommand:
11
+ """A single slash command implementation."""
12
+
13
+ name: str
14
+ description: str
15
+ handler: Handler
16
+ aliases: Tuple[str, ...] = ()
17
+
18
+
19
+ __all__ = ["SlashCommand", "Handler"]
@@ -0,0 +1,18 @@
1
+ from typing import Any
2
+ from .base import SlashCommand
3
+
4
+
5
+ def _handle(ui: Any, _: str) -> bool:
6
+ ui.conversation_messages = []
7
+ ui.console.print("[green]✓ Conversation cleared[/green]")
8
+ return True
9
+
10
+
11
+ command = SlashCommand(
12
+ name="clear",
13
+ description="Clear conversation history",
14
+ handler=_handle,
15
+ )
16
+
17
+
18
+ __all__ = ["command"]