ripperdoc 0.1.0__py3-none-any.whl → 0.2.2__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 (57) hide show
  1. ripperdoc/__init__.py +1 -1
  2. ripperdoc/cli/cli.py +75 -15
  3. ripperdoc/cli/commands/__init__.py +4 -0
  4. ripperdoc/cli/commands/agents_cmd.py +23 -1
  5. ripperdoc/cli/commands/context_cmd.py +13 -3
  6. ripperdoc/cli/commands/cost_cmd.py +1 -1
  7. ripperdoc/cli/commands/doctor_cmd.py +200 -0
  8. ripperdoc/cli/commands/memory_cmd.py +209 -0
  9. ripperdoc/cli/commands/models_cmd.py +25 -0
  10. ripperdoc/cli/commands/resume_cmd.py +3 -3
  11. ripperdoc/cli/commands/status_cmd.py +5 -5
  12. ripperdoc/cli/commands/tasks_cmd.py +32 -5
  13. ripperdoc/cli/ui/context_display.py +4 -3
  14. ripperdoc/cli/ui/rich_ui.py +205 -43
  15. ripperdoc/cli/ui/spinner.py +3 -4
  16. ripperdoc/core/agents.py +10 -6
  17. ripperdoc/core/config.py +48 -3
  18. ripperdoc/core/default_tools.py +26 -6
  19. ripperdoc/core/permissions.py +19 -0
  20. ripperdoc/core/query.py +238 -302
  21. ripperdoc/core/query_utils.py +537 -0
  22. ripperdoc/core/system_prompt.py +2 -1
  23. ripperdoc/core/tool.py +14 -1
  24. ripperdoc/sdk/client.py +1 -1
  25. ripperdoc/tools/background_shell.py +9 -3
  26. ripperdoc/tools/bash_tool.py +19 -4
  27. ripperdoc/tools/file_edit_tool.py +9 -2
  28. ripperdoc/tools/file_read_tool.py +9 -2
  29. ripperdoc/tools/file_write_tool.py +15 -2
  30. ripperdoc/tools/glob_tool.py +57 -17
  31. ripperdoc/tools/grep_tool.py +9 -2
  32. ripperdoc/tools/ls_tool.py +244 -75
  33. ripperdoc/tools/mcp_tools.py +47 -19
  34. ripperdoc/tools/multi_edit_tool.py +13 -2
  35. ripperdoc/tools/notebook_edit_tool.py +9 -6
  36. ripperdoc/tools/task_tool.py +20 -5
  37. ripperdoc/tools/todo_tool.py +163 -29
  38. ripperdoc/tools/tool_search_tool.py +15 -4
  39. ripperdoc/utils/git_utils.py +276 -0
  40. ripperdoc/utils/json_utils.py +28 -0
  41. ripperdoc/utils/log.py +130 -29
  42. ripperdoc/utils/mcp.py +83 -10
  43. ripperdoc/utils/memory.py +14 -1
  44. ripperdoc/utils/message_compaction.py +51 -14
  45. ripperdoc/utils/messages.py +63 -4
  46. ripperdoc/utils/output_utils.py +36 -9
  47. ripperdoc/utils/permissions/path_validation_utils.py +6 -0
  48. ripperdoc/utils/safe_get_cwd.py +4 -0
  49. ripperdoc/utils/session_history.py +27 -9
  50. ripperdoc/utils/todo.py +2 -2
  51. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/METADATA +4 -2
  52. ripperdoc-0.2.2.dist-info/RECORD +86 -0
  53. ripperdoc-0.1.0.dist-info/RECORD +0 -81
  54. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/WHEEL +0 -0
  55. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/entry_points.txt +0 -0
  56. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/licenses/LICENSE +0 -0
  57. {ripperdoc-0.1.0.dist-info → ripperdoc-0.2.2.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py CHANGED
@@ -1,3 +1,3 @@
1
1
  """Ripperdoc - AI-powered coding agent."""
2
2
 
3
- __version__ = "0.1.0"
3
+ __version__ = "0.2.2"
ripperdoc/cli/cli.py CHANGED
@@ -6,8 +6,9 @@ This module provides the command-line interface for the Ripperdoc agent.
6
6
  import asyncio
7
7
  import click
8
8
  import sys
9
+ import uuid
9
10
  from pathlib import Path
10
- from typing import Optional
11
+ from typing import Any, Dict, List, Optional
11
12
 
12
13
  from ripperdoc import __version__
13
14
  from ripperdoc.core.config import (
@@ -29,6 +30,7 @@ from ripperdoc.utils.mcp import (
29
30
  shutdown_mcp_runtime,
30
31
  )
31
32
  from ripperdoc.tools.mcp_tools import load_dynamic_mcp_tools_async, merge_tools_with_dynamic
33
+ from ripperdoc.utils.log import enable_session_file_logging, get_logger
32
34
 
33
35
  from rich.console import Console
34
36
  from rich.markdown import Markdown
@@ -36,24 +38,46 @@ from rich.panel import Panel
36
38
  from rich.markup import escape
37
39
 
38
40
  console = Console()
41
+ logger = get_logger()
39
42
 
40
43
 
41
44
  async def run_query(
42
- prompt: str, tools: list, safe_mode: bool = False, verbose: bool = False
45
+ prompt: str,
46
+ tools: list,
47
+ safe_mode: bool = False,
48
+ verbose: bool = False,
49
+ session_id: Optional[str] = None,
43
50
  ) -> None:
44
51
  """Run a single query and print the response."""
45
52
 
53
+ logger.info(
54
+ "[cli] Running single prompt session",
55
+ extra={
56
+ "safe_mode": safe_mode,
57
+ "verbose": verbose,
58
+ "session_id": session_id,
59
+ "prompt_length": len(prompt),
60
+ },
61
+ )
62
+ if prompt:
63
+ logger.debug(
64
+ "[cli] Prompt preview",
65
+ extra={"session_id": session_id, "prompt_preview": prompt[:200]},
66
+ )
67
+
46
68
  project_path = Path.cwd()
47
69
  can_use_tool = make_permission_checker(project_path, safe_mode) if safe_mode else None
48
70
 
49
71
  # Create initial user message
50
- messages = [create_user_message(prompt)]
72
+ from ripperdoc.utils.messages import UserMessage, AssistantMessage, ProgressMessage
73
+
74
+ messages: List[UserMessage | AssistantMessage | ProgressMessage] = [create_user_message(prompt)]
51
75
 
52
76
  # Create query context
53
77
  query_context = QueryContext(tools=tools, safe_mode=safe_mode, verbose=verbose)
54
78
 
55
79
  try:
56
- context = {}
80
+ context: Dict[str, Any] = {}
57
81
  # System prompt
58
82
  servers = await load_mcp_servers_async(Path.cwd())
59
83
  dynamic_tools = await load_dynamic_mcp_tools_async(Path.cwd())
@@ -79,7 +103,7 @@ async def run_query(
79
103
  async for message in query(
80
104
  messages, system_prompt, context, query_context, can_use_tool
81
105
  ):
82
- if message.type == "assistant":
106
+ if message.type == "assistant" and hasattr(message, "message"):
83
107
  # Print assistant message
84
108
  if isinstance(message.message.content, str):
85
109
  console.print(
@@ -105,30 +129,36 @@ async def run_query(
105
129
  if hasattr(block, "type") and block.type == "text":
106
130
  console.print(
107
131
  Panel(
108
- Markdown(block.text),
132
+ Markdown(block.text or ""),
109
133
  title="Ripperdoc",
110
134
  border_style="cyan",
111
135
  )
112
136
  )
113
137
 
114
- elif message.type == "progress":
138
+ elif message.type == "progress" and hasattr(message, "content"):
115
139
  # Print progress
116
140
  if verbose:
117
141
  console.print(f"[dim]Progress: {escape(str(message.content))}[/dim]")
118
142
 
119
143
  # Add message to history
120
- messages.append(message)
144
+ messages.append(message) # type: ignore[arg-type]
121
145
 
122
146
  except KeyboardInterrupt:
123
147
  console.print("\n[yellow]Interrupted by user[/yellow]")
124
148
  except Exception as e:
125
149
  console.print(f"[red]Error: {escape(str(e))}[/red]")
150
+ logger.exception("[cli] Unhandled error while running prompt", extra={"session_id": session_id})
126
151
  if verbose:
127
152
  import traceback
128
153
 
129
154
  console.print(traceback.format_exc(), markup=False)
155
+ logger.info(
156
+ "[cli] Prompt session completed",
157
+ extra={"session_id": session_id, "message_count": len(messages)},
158
+ )
130
159
  finally:
131
160
  await shutdown_mcp_runtime()
161
+ logger.debug("[cli] Shutdown MCP runtime", extra={"session_id": session_id})
132
162
 
133
163
 
134
164
  def check_onboarding() -> bool:
@@ -241,27 +271,51 @@ def cli(
241
271
  ctx: click.Context, cwd: Optional[str], unsafe: bool, verbose: bool, prompt: Optional[str]
242
272
  ) -> None:
243
273
  """Ripperdoc - AI-powered coding agent"""
244
-
245
- # Ensure onboarding is complete
246
- if not check_onboarding():
247
- sys.exit(1)
274
+ session_id = str(uuid.uuid4())
248
275
 
249
276
  # Set working directory
250
277
  if cwd:
251
278
  import os
252
279
 
253
280
  os.chdir(cwd)
281
+ logger.debug(
282
+ "[cli] Changed working directory via --cwd",
283
+ extra={"cwd": cwd, "session_id": session_id},
284
+ )
254
285
 
255
- # Initialize project configuration for the current working directory
256
286
  project_path = Path.cwd()
287
+ log_file = enable_session_file_logging(project_path, session_id)
288
+ logger.info(
289
+ "[cli] Starting CLI invocation",
290
+ extra={
291
+ "session_id": session_id,
292
+ "project_path": str(project_path),
293
+ "log_file": str(log_file),
294
+ "prompt_mode": bool(prompt),
295
+ },
296
+ )
297
+
298
+ # Ensure onboarding is complete
299
+ if not check_onboarding():
300
+ logger.info(
301
+ "[cli] Onboarding check failed or aborted; exiting.",
302
+ extra={"session_id": session_id},
303
+ )
304
+ sys.exit(1)
305
+
306
+ # Initialize project configuration for the current working directory
257
307
  get_project_config(project_path)
258
308
 
259
309
  safe_mode = not unsafe
310
+ logger.debug(
311
+ "[cli] Configuration initialized",
312
+ extra={"session_id": session_id, "safe_mode": safe_mode, "verbose": verbose},
313
+ )
260
314
 
261
315
  # If prompt is provided, run directly
262
316
  if prompt:
263
317
  tools = get_default_tools()
264
- asyncio.run(run_query(prompt, tools, safe_mode, verbose))
318
+ asyncio.run(run_query(prompt, tools, safe_mode, verbose, session_id=session_id))
265
319
  return
266
320
 
267
321
  # If no command specified, start interactive REPL with Rich interface
@@ -269,7 +323,12 @@ def cli(
269
323
  # Use Rich interface by default
270
324
  from ripperdoc.cli.ui.rich_ui import main_rich
271
325
 
272
- main_rich(safe_mode=safe_mode, verbose=verbose)
326
+ main_rich(
327
+ safe_mode=safe_mode,
328
+ verbose=verbose,
329
+ session_id=session_id,
330
+ log_file_path=log_file,
331
+ )
273
332
  return
274
333
 
275
334
 
@@ -310,6 +369,7 @@ def main() -> None:
310
369
  sys.exit(130)
311
370
  except Exception as e:
312
371
  console.print(f"[red]Fatal error: {escape(str(e))}[/red]")
372
+ logger.exception("[cli] Fatal error in main CLI entrypoint")
313
373
  sys.exit(1)
314
374
 
315
375
 
@@ -11,8 +11,10 @@ from .compact_cmd import command as compact_command
11
11
  from .config_cmd import command as config_command
12
12
  from .cost_cmd import command as cost_command
13
13
  from .context_cmd import command as context_command
14
+ from .doctor_cmd import command as doctor_command
14
15
  from .exit_cmd import command as exit_command
15
16
  from .help_cmd import command as help_command
17
+ from .memory_cmd import command as memory_command
16
18
  from .mcp_cmd import command as mcp_command
17
19
  from .models_cmd import command as models_command
18
20
  from .resume_cmd import command as resume_command
@@ -40,6 +42,8 @@ ALL_COMMANDS: List[SlashCommand] = [
40
42
  models_command,
41
43
  exit_command,
42
44
  status_command,
45
+ doctor_command,
46
+ memory_command,
43
47
  tasks_command,
44
48
  todos_command,
45
49
  mcp_command,
@@ -8,15 +8,25 @@ from ripperdoc.core.agents import (
8
8
  save_agent_definition,
9
9
  )
10
10
  from ripperdoc.core.config import get_global_config
11
+ from ripperdoc.utils.log import get_logger
11
12
 
12
13
  from typing import Any
13
14
  from .base import SlashCommand
14
15
 
16
+ logger = get_logger()
17
+
15
18
 
16
19
  def _handle(ui: Any, trimmed_arg: str) -> bool:
17
20
  console = ui.console
18
21
  tokens = trimmed_arg.split()
19
22
  subcmd = tokens[0].lower() if tokens else ""
23
+ logger.info(
24
+ "[agents_cmd] Handling /agents command",
25
+ extra={
26
+ "subcommand": subcmd or "list",
27
+ "session_id": getattr(ui, "session_id", None),
28
+ },
29
+ )
20
30
 
21
31
  def print_agents_usage() -> None:
22
32
  console.print("[bold]/agents[/bold] — list configured agents")
@@ -100,6 +110,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
100
110
  except Exception as exc:
101
111
  console.print(f"[red]Failed to create agent: {escape(str(exc))}[/red]")
102
112
  print_agents_usage()
113
+ logger.exception(
114
+ "[agents_cmd] Failed to create agent",
115
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
116
+ )
103
117
  return True
104
118
 
105
119
  if subcmd in ("delete", "del", "remove"):
@@ -127,6 +141,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
127
141
  except Exception as exc:
128
142
  console.print(f"[red]Failed to delete agent: {escape(str(exc))}[/red]")
129
143
  print_agents_usage()
144
+ logger.exception(
145
+ "[agents_cmd] Failed to delete agent",
146
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
147
+ )
130
148
  return True
131
149
 
132
150
  if subcmd in ("edit", "update"):
@@ -172,7 +190,7 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
172
190
  console.print(escape(target_agent.system_prompt or "(empty)"), markup=False)
173
191
  system_prompt = (
174
192
  console.input(
175
- "System prompt (single line, use \\n for newlines) " "[Enter to keep current]: "
193
+ "System prompt (single line, use \\n for newlines) [Enter to keep current]: "
176
194
  ).strip()
177
195
  or target_agent.system_prompt
178
196
  )
@@ -200,6 +218,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
200
218
  except Exception as exc:
201
219
  console.print(f"[red]Failed to update agent: {escape(str(exc))}[/red]")
202
220
  print_agents_usage()
221
+ logger.exception(
222
+ "[agents_cmd] Failed to update agent",
223
+ extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
224
+ )
203
225
  return True
204
226
 
205
227
  agents = load_agent_definitions()
@@ -1,5 +1,6 @@
1
1
  import asyncio
2
2
  import json
3
+ from typing import List, Any
3
4
 
4
5
  from ripperdoc.cli.ui.helpers import get_profile_for_pointer
5
6
  from ripperdoc.cli.ui.context_display import format_tokens
@@ -19,12 +20,18 @@ from ripperdoc.utils.mcp import (
19
20
  load_mcp_servers_async,
20
21
  shutdown_mcp_runtime,
21
22
  )
23
+ from ripperdoc.utils.log import get_logger
22
24
 
23
- from typing import Any
24
25
  from .base import SlashCommand
25
26
 
27
+ logger = get_logger()
28
+
26
29
 
27
30
  def _handle(ui: Any, _: str) -> bool:
31
+ logger.info(
32
+ "[context_cmd] Rendering context summary",
33
+ extra={"session_id": getattr(ui, "session_id", None)},
34
+ )
28
35
  config = get_global_config()
29
36
  model_profile = get_profile_for_pointer("main")
30
37
  max_context_tokens = get_remaining_context_tokens(model_profile, config.context_token_limit)
@@ -38,7 +45,7 @@ def _handle(ui: Any, _: str) -> bool:
38
45
  verbose=ui.verbose,
39
46
  )
40
47
 
41
- async def _load_servers():
48
+ async def _load_servers() -> List[Any]:
42
49
  try:
43
50
  return await load_mcp_servers_async(ui.project_path)
44
51
  finally:
@@ -98,7 +105,10 @@ def _handle(ui: Any, _: str) -> bool:
98
105
  if len(mcp_tools) > 20:
99
106
  lines.append(f" └ ... (+{len(mcp_tools) - 20} more)")
100
107
  except Exception:
101
- pass
108
+ logger.exception(
109
+ "[context_cmd] Failed to summarize MCP tools",
110
+ extra={"session_id": getattr(ui, "session_id", None)},
111
+ )
102
112
  for line in lines:
103
113
  ui.console.print(line)
104
114
  return True
@@ -58,7 +58,7 @@ def _handle(ui: Any, _: str) -> bool:
58
58
  line += f", {_fmt_tokens(stats.cache_read_input_tokens)} cache read"
59
59
  if stats.cache_creation_input_tokens:
60
60
  line += f", {_fmt_tokens(stats.cache_creation_input_tokens)} cache write"
61
- line += f" ({stats.requests} call" f"{'' if stats.requests == 1 else 's'}"
61
+ line += f" ({stats.requests} call{'' if stats.requests == 1 else 's'}"
62
62
  if stats.duration_ms:
63
63
  line += f", {_format_duration(stats.duration_ms)} total"
64
64
  line += ")"
@@ -0,0 +1,200 @@
1
+ """Slash command to diagnose common setup issues."""
2
+
3
+ from __future__ import annotations
4
+
5
+ import asyncio
6
+ from pathlib import Path
7
+ from typing import Any, 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, shutdown_mcp_runtime
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
+ for env_var in api_key_env_candidates(provider):
44
+ if os.environ.get(env_var):
45
+ masked = os.environ[env_var]
46
+ masked = masked[:4] + "…" if len(masked) > 4 else "set"
47
+ return ("ok", f"Found in ${env_var} ({masked})")
48
+
49
+ if profile_key:
50
+ return ("ok", "Stored in config profile")
51
+
52
+ return ("error", "Missing API key for active provider; set $ENV or edit config")
53
+
54
+
55
+ def _model_status(project_path: Path) -> List[Tuple[str, str, str]]:
56
+ config = get_global_config()
57
+ pointer = getattr(config.model_pointers, "main", "default")
58
+ profile = get_profile_for_pointer("main")
59
+ rows: List[Tuple[str, str, str]] = []
60
+
61
+ if not profile:
62
+ rows.append(_status_row("Model profile", "error", "No profile configured for pointer 'main'"))
63
+ return rows
64
+
65
+ if pointer not in config.model_profiles:
66
+ rows.append(
67
+ _status_row(
68
+ "Model pointer",
69
+ "warn",
70
+ f"Pointer 'main' targets '{pointer}' which is missing; using fallback.",
71
+ )
72
+ )
73
+ rows.append(
74
+ _status_row(
75
+ "Model",
76
+ "ok",
77
+ f"{profile.model} ({profile.provider.value})",
78
+ )
79
+ )
80
+
81
+ key_status, key_detail = _api_key_status(profile.provider, profile.api_key)
82
+ rows.append(_status_row("API key", key_status, key_detail))
83
+ return rows
84
+
85
+
86
+ def _onboarding_status() -> Tuple[str, str, str]:
87
+ config = get_global_config()
88
+ if config.has_completed_onboarding:
89
+ return _status_row(
90
+ "Onboarding",
91
+ "ok",
92
+ f"Completed (version {str(config.last_onboarding_version or 'unknown')})",
93
+ )
94
+ return _status_row(
95
+ "Onboarding",
96
+ "warn",
97
+ "Not completed; run the CLI without flags to configure provider/model.",
98
+ )
99
+
100
+
101
+ def _sandbox_status() -> Tuple[str, str, str]:
102
+ available = is_sandbox_available()
103
+ if available:
104
+ return _status_row("Sandbox", "ok", "'srt' runtime is available")
105
+ return _status_row("Sandbox", "warn", "Sandbox runtime not detected; commands run normally")
106
+
107
+
108
+ def _mcp_status(project_path: Path) -> Tuple[List[Tuple[str, str, str]], List[str]]:
109
+ """Return MCP status rows and errors."""
110
+ rows: List[Tuple[str, str, str]] = []
111
+ errors: List[str] = []
112
+
113
+ async def _load() -> List[Any]:
114
+ try:
115
+ return await load_mcp_servers_async(project_path)
116
+ finally:
117
+ await shutdown_mcp_runtime()
118
+
119
+ try:
120
+ servers = asyncio.run(_load())
121
+ except Exception as exc: # pragma: no cover - defensive
122
+ logger.exception("[doctor] Failed to load MCP servers", exc_info=exc)
123
+ rows.append(_status_row("MCP", "error", f"Failed to load MCP config: {exc}"))
124
+ return rows, errors
125
+
126
+ if not servers:
127
+ rows.append(_status_row("MCP", "warn", "No MCP servers configured (.mcp.json)"))
128
+ return rows, errors
129
+
130
+ failing = [s for s in servers if getattr(s, "error", None)]
131
+ rows.append(
132
+ _status_row(
133
+ "MCP",
134
+ "ok" if not failing else "warn",
135
+ f"{len(servers)} configured; {len(failing)} with errors",
136
+ )
137
+ )
138
+ for server in failing[:5]:
139
+ errors.append(f"{server.name}: {server.error}")
140
+ if len(failing) > 5:
141
+ errors.append(f"... {len(failing) - 5} more")
142
+ return rows, errors
143
+
144
+
145
+ def _project_status(project_path: Path) -> Tuple[str, str, str]:
146
+ try:
147
+ config = get_project_config(project_path)
148
+ # Access a field to ensure model parsing does not throw.
149
+ _ = len(config.allowed_tools)
150
+ return _status_row("Project config", "ok", f".ripperdoc/config.json loaded for {project_path}")
151
+ except Exception as exc: # pragma: no cover - defensive
152
+ logger.exception("[doctor] Failed to load project config", exc_info=exc)
153
+ return _status_row("Project config", "warn", f"Could not read .ripperdoc/config.json: {exc}")
154
+
155
+
156
+ def _render_table(console: Any, rows: List[Tuple[str, str, str]]) -> None:
157
+ table = Table(show_header=True, header_style="bold cyan")
158
+ table.add_column("Check")
159
+ table.add_column("")
160
+ table.add_column("Details")
161
+ for label, status, detail in rows:
162
+ table.add_row(label, status, escape(detail) if detail else "")
163
+ console.print(table)
164
+
165
+
166
+ def _handle(ui: Any, _: str) -> bool:
167
+ project_path = getattr(ui, "project_path", Path.cwd())
168
+ results: List[Tuple[str, str, str]] = []
169
+
170
+ results.append(_onboarding_status())
171
+ results.extend(_model_status(project_path))
172
+ project_row = _project_status(project_path)
173
+ results.append(project_row)
174
+
175
+ mcp_rows, mcp_errors = _mcp_status(project_path)
176
+ results.extend(mcp_rows)
177
+ results.append(_sandbox_status())
178
+
179
+ ui.console.print(Panel("Environment diagnostics", title="/doctor", border_style="cyan"))
180
+ _render_table(ui.console, results)
181
+
182
+ if mcp_errors:
183
+ ui.console.print("\n[bold]MCP issues:[/bold]")
184
+ for err in mcp_errors:
185
+ ui.console.print(f" • {escape(err)}")
186
+
187
+ ui.console.print(
188
+ "\n[dim]If a check is failing, run `ripperdoc` without flags to rerun onboarding or update ~/.ripperdoc.json[/dim]"
189
+ )
190
+ return True
191
+
192
+
193
+ command = SlashCommand(
194
+ name="doctor",
195
+ description="Diagnose model config, API keys, MCP, and sandbox support",
196
+ handler=_handle,
197
+ )
198
+
199
+
200
+ __all__ = ["command"]