ripperdoc 0.2.0__py3-none-any.whl → 0.2.3__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 +1 -1
- ripperdoc/cli/cli.py +74 -9
- ripperdoc/cli/commands/__init__.py +4 -0
- ripperdoc/cli/commands/agents_cmd.py +30 -4
- ripperdoc/cli/commands/context_cmd.py +11 -1
- ripperdoc/cli/commands/cost_cmd.py +5 -0
- ripperdoc/cli/commands/doctor_cmd.py +208 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +61 -6
- ripperdoc/cli/commands/resume_cmd.py +4 -2
- ripperdoc/cli/commands/status_cmd.py +1 -1
- ripperdoc/cli/commands/tasks_cmd.py +27 -0
- ripperdoc/cli/ui/rich_ui.py +258 -11
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/core/agents.py +14 -4
- ripperdoc/core/config.py +56 -3
- ripperdoc/core/default_tools.py +16 -2
- ripperdoc/core/permissions.py +19 -0
- ripperdoc/core/providers/__init__.py +31 -0
- ripperdoc/core/providers/anthropic.py +136 -0
- ripperdoc/core/providers/base.py +187 -0
- ripperdoc/core/providers/gemini.py +172 -0
- ripperdoc/core/providers/openai.py +142 -0
- ripperdoc/core/query.py +510 -386
- ripperdoc/core/query_utils.py +578 -0
- ripperdoc/core/system_prompt.py +2 -1
- ripperdoc/core/tool.py +16 -1
- ripperdoc/sdk/client.py +12 -1
- ripperdoc/tools/background_shell.py +63 -21
- ripperdoc/tools/bash_tool.py +48 -13
- ripperdoc/tools/file_edit_tool.py +20 -0
- ripperdoc/tools/file_read_tool.py +23 -0
- ripperdoc/tools/file_write_tool.py +20 -0
- ripperdoc/tools/glob_tool.py +59 -15
- ripperdoc/tools/grep_tool.py +7 -0
- ripperdoc/tools/ls_tool.py +246 -73
- ripperdoc/tools/mcp_tools.py +32 -10
- ripperdoc/tools/multi_edit_tool.py +23 -0
- ripperdoc/tools/notebook_edit_tool.py +18 -3
- ripperdoc/tools/task_tool.py +7 -0
- ripperdoc/tools/todo_tool.py +157 -25
- ripperdoc/tools/tool_search_tool.py +17 -4
- ripperdoc/utils/file_watch.py +134 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +129 -29
- ripperdoc/utils/mcp.py +71 -6
- ripperdoc/utils/memory.py +12 -1
- ripperdoc/utils/message_compaction.py +22 -5
- ripperdoc/utils/messages.py +72 -17
- ripperdoc/utils/output_utils.py +34 -9
- ripperdoc/utils/permissions/path_validation_utils.py +6 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +4 -0
- ripperdoc/utils/session_history.py +27 -9
- ripperdoc/utils/session_usage.py +7 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +2 -2
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/METADATA +4 -2
- ripperdoc-0.2.3.dist-info/RECORD +95 -0
- ripperdoc-0.2.0.dist-info/RECORD +0 -81
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.0.dist-info → ripperdoc-0.2.3.dist-info}/top_level.txt +0 -0
ripperdoc/__init__.py
CHANGED
ripperdoc/cli/cli.py
CHANGED
|
@@ -6,6 +6,7 @@ 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
11
|
from typing import Any, Dict, List, Optional
|
|
11
12
|
|
|
@@ -29,6 +30,8 @@ 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
|
|
34
|
+
from ripperdoc.utils.prompt import prompt_secret
|
|
32
35
|
|
|
33
36
|
from rich.console import Console
|
|
34
37
|
from rich.markdown import Markdown
|
|
@@ -36,13 +39,33 @@ from rich.panel import Panel
|
|
|
36
39
|
from rich.markup import escape
|
|
37
40
|
|
|
38
41
|
console = Console()
|
|
42
|
+
logger = get_logger()
|
|
39
43
|
|
|
40
44
|
|
|
41
45
|
async def run_query(
|
|
42
|
-
prompt: str,
|
|
46
|
+
prompt: str,
|
|
47
|
+
tools: list,
|
|
48
|
+
safe_mode: bool = False,
|
|
49
|
+
verbose: bool = False,
|
|
50
|
+
session_id: Optional[str] = None,
|
|
43
51
|
) -> None:
|
|
44
52
|
"""Run a single query and print the response."""
|
|
45
53
|
|
|
54
|
+
logger.info(
|
|
55
|
+
"[cli] Running single prompt session",
|
|
56
|
+
extra={
|
|
57
|
+
"safe_mode": safe_mode,
|
|
58
|
+
"verbose": verbose,
|
|
59
|
+
"session_id": session_id,
|
|
60
|
+
"prompt_length": len(prompt),
|
|
61
|
+
},
|
|
62
|
+
)
|
|
63
|
+
if prompt:
|
|
64
|
+
logger.debug(
|
|
65
|
+
"[cli] Prompt preview",
|
|
66
|
+
extra={"session_id": session_id, "prompt_preview": prompt[:200]},
|
|
67
|
+
)
|
|
68
|
+
|
|
46
69
|
project_path = Path.cwd()
|
|
47
70
|
can_use_tool = make_permission_checker(project_path, safe_mode) if safe_mode else None
|
|
48
71
|
|
|
@@ -125,12 +148,20 @@ async def run_query(
|
|
|
125
148
|
console.print("\n[yellow]Interrupted by user[/yellow]")
|
|
126
149
|
except Exception as e:
|
|
127
150
|
console.print(f"[red]Error: {escape(str(e))}[/red]")
|
|
151
|
+
logger.exception(
|
|
152
|
+
"[cli] Unhandled error while running prompt", extra={"session_id": session_id}
|
|
153
|
+
)
|
|
128
154
|
if verbose:
|
|
129
155
|
import traceback
|
|
130
156
|
|
|
131
157
|
console.print(traceback.format_exc(), markup=False)
|
|
158
|
+
logger.info(
|
|
159
|
+
"[cli] Prompt session completed",
|
|
160
|
+
extra={"session_id": session_id, "message_count": len(messages)},
|
|
161
|
+
)
|
|
132
162
|
finally:
|
|
133
163
|
await shutdown_mcp_runtime()
|
|
164
|
+
logger.debug("[cli] Shutdown MCP runtime", extra={"session_id": session_id})
|
|
134
165
|
|
|
135
166
|
|
|
136
167
|
def check_onboarding() -> bool:
|
|
@@ -169,7 +200,11 @@ def check_onboarding() -> bool:
|
|
|
169
200
|
)
|
|
170
201
|
api_base = click.prompt("API Base URL")
|
|
171
202
|
|
|
172
|
-
api_key =
|
|
203
|
+
api_key = ""
|
|
204
|
+
while not api_key:
|
|
205
|
+
api_key = prompt_secret("Enter your API key").strip()
|
|
206
|
+
if not api_key:
|
|
207
|
+
console.print("[red]API key is required.[/red]")
|
|
173
208
|
|
|
174
209
|
provider = ProviderType(provider_choice)
|
|
175
210
|
|
|
@@ -243,27 +278,51 @@ def cli(
|
|
|
243
278
|
ctx: click.Context, cwd: Optional[str], unsafe: bool, verbose: bool, prompt: Optional[str]
|
|
244
279
|
) -> None:
|
|
245
280
|
"""Ripperdoc - AI-powered coding agent"""
|
|
246
|
-
|
|
247
|
-
# Ensure onboarding is complete
|
|
248
|
-
if not check_onboarding():
|
|
249
|
-
sys.exit(1)
|
|
281
|
+
session_id = str(uuid.uuid4())
|
|
250
282
|
|
|
251
283
|
# Set working directory
|
|
252
284
|
if cwd:
|
|
253
285
|
import os
|
|
254
286
|
|
|
255
287
|
os.chdir(cwd)
|
|
288
|
+
logger.debug(
|
|
289
|
+
"[cli] Changed working directory via --cwd",
|
|
290
|
+
extra={"cwd": cwd, "session_id": session_id},
|
|
291
|
+
)
|
|
256
292
|
|
|
257
|
-
# Initialize project configuration for the current working directory
|
|
258
293
|
project_path = Path.cwd()
|
|
294
|
+
log_file = enable_session_file_logging(project_path, session_id)
|
|
295
|
+
logger.info(
|
|
296
|
+
"[cli] Starting CLI invocation",
|
|
297
|
+
extra={
|
|
298
|
+
"session_id": session_id,
|
|
299
|
+
"project_path": str(project_path),
|
|
300
|
+
"log_file": str(log_file),
|
|
301
|
+
"prompt_mode": bool(prompt),
|
|
302
|
+
},
|
|
303
|
+
)
|
|
304
|
+
|
|
305
|
+
# Ensure onboarding is complete
|
|
306
|
+
if not check_onboarding():
|
|
307
|
+
logger.info(
|
|
308
|
+
"[cli] Onboarding check failed or aborted; exiting.",
|
|
309
|
+
extra={"session_id": session_id},
|
|
310
|
+
)
|
|
311
|
+
sys.exit(1)
|
|
312
|
+
|
|
313
|
+
# Initialize project configuration for the current working directory
|
|
259
314
|
get_project_config(project_path)
|
|
260
315
|
|
|
261
316
|
safe_mode = not unsafe
|
|
317
|
+
logger.debug(
|
|
318
|
+
"[cli] Configuration initialized",
|
|
319
|
+
extra={"session_id": session_id, "safe_mode": safe_mode, "verbose": verbose},
|
|
320
|
+
)
|
|
262
321
|
|
|
263
322
|
# If prompt is provided, run directly
|
|
264
323
|
if prompt:
|
|
265
324
|
tools = get_default_tools()
|
|
266
|
-
asyncio.run(run_query(prompt, tools, safe_mode, verbose))
|
|
325
|
+
asyncio.run(run_query(prompt, tools, safe_mode, verbose, session_id=session_id))
|
|
267
326
|
return
|
|
268
327
|
|
|
269
328
|
# If no command specified, start interactive REPL with Rich interface
|
|
@@ -271,7 +330,12 @@ def cli(
|
|
|
271
330
|
# Use Rich interface by default
|
|
272
331
|
from ripperdoc.cli.ui.rich_ui import main_rich
|
|
273
332
|
|
|
274
|
-
main_rich(
|
|
333
|
+
main_rich(
|
|
334
|
+
safe_mode=safe_mode,
|
|
335
|
+
verbose=verbose,
|
|
336
|
+
session_id=session_id,
|
|
337
|
+
log_file_path=log_file,
|
|
338
|
+
)
|
|
275
339
|
return
|
|
276
340
|
|
|
277
341
|
|
|
@@ -312,6 +376,7 @@ def main() -> None:
|
|
|
312
376
|
sys.exit(130)
|
|
313
377
|
except Exception as e:
|
|
314
378
|
console.print(f"[red]Fatal error: {escape(str(e))}[/red]")
|
|
379
|
+
logger.exception("[cli] Fatal error in main CLI entrypoint")
|
|
315
380
|
sys.exit(1)
|
|
316
381
|
|
|
317
382
|
|
|
@@ -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,27 +8,40 @@ 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")
|
|
23
33
|
console.print(
|
|
24
|
-
"[bold]/agents create <name> [location] [model][/bold] —
|
|
34
|
+
"[bold]/agents create <name> [location] [model][/bold] — "
|
|
35
|
+
"create agent (location: user|project, default user)"
|
|
25
36
|
)
|
|
26
37
|
console.print("[bold]/agents edit <name> [location][/bold] — edit an existing agent")
|
|
27
38
|
console.print(
|
|
28
|
-
"[bold]/agents delete <name> [location][/bold] —
|
|
39
|
+
"[bold]/agents delete <name> [location][/bold] — "
|
|
40
|
+
"delete agent (location: user|project, default user)"
|
|
29
41
|
)
|
|
30
42
|
console.print(
|
|
31
|
-
f"[dim]Agent files live in ~/.ripperdoc/{AGENT_DIR_NAME}
|
|
43
|
+
f"[dim]Agent files live in ~/.ripperdoc/{AGENT_DIR_NAME} "
|
|
44
|
+
f"or ./.ripperdoc/{AGENT_DIR_NAME}[/dim]"
|
|
32
45
|
)
|
|
33
46
|
console.print(
|
|
34
47
|
"[dim]Model can be a profile name or pointer (task/main/etc). Defaults to 'task'.[/dim]"
|
|
@@ -82,7 +95,8 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
82
95
|
and model_input not in pointer_map
|
|
83
96
|
):
|
|
84
97
|
console.print(
|
|
85
|
-
"[yellow]Model not found in profiles or pointers;
|
|
98
|
+
"[yellow]Model not found in profiles or pointers; "
|
|
99
|
+
"will fall back to main if unavailable.[/yellow]"
|
|
86
100
|
)
|
|
87
101
|
|
|
88
102
|
try:
|
|
@@ -100,6 +114,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
100
114
|
except Exception as exc:
|
|
101
115
|
console.print(f"[red]Failed to create agent: {escape(str(exc))}[/red]")
|
|
102
116
|
print_agents_usage()
|
|
117
|
+
logger.exception(
|
|
118
|
+
"[agents_cmd] Failed to create agent",
|
|
119
|
+
extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
|
|
120
|
+
)
|
|
103
121
|
return True
|
|
104
122
|
|
|
105
123
|
if subcmd in ("delete", "del", "remove"):
|
|
@@ -127,6 +145,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
127
145
|
except Exception as exc:
|
|
128
146
|
console.print(f"[red]Failed to delete agent: {escape(str(exc))}[/red]")
|
|
129
147
|
print_agents_usage()
|
|
148
|
+
logger.exception(
|
|
149
|
+
"[agents_cmd] Failed to delete agent",
|
|
150
|
+
extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
|
|
151
|
+
)
|
|
130
152
|
return True
|
|
131
153
|
|
|
132
154
|
if subcmd in ("edit", "update"):
|
|
@@ -200,6 +222,10 @@ def _handle(ui: Any, trimmed_arg: str) -> bool:
|
|
|
200
222
|
except Exception as exc:
|
|
201
223
|
console.print(f"[red]Failed to update agent: {escape(str(exc))}[/red]")
|
|
202
224
|
print_agents_usage()
|
|
225
|
+
logger.exception(
|
|
226
|
+
"[agents_cmd] Failed to update agent",
|
|
227
|
+
extra={"agent": agent_name, "session_id": getattr(ui, "session_id", None)},
|
|
228
|
+
)
|
|
203
229
|
return True
|
|
204
230
|
|
|
205
231
|
agents = load_agent_definitions()
|
|
@@ -20,11 +20,18 @@ from ripperdoc.utils.mcp import (
|
|
|
20
20
|
load_mcp_servers_async,
|
|
21
21
|
shutdown_mcp_runtime,
|
|
22
22
|
)
|
|
23
|
+
from ripperdoc.utils.log import get_logger
|
|
23
24
|
|
|
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)
|
|
@@ -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
|
-
|
|
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
|
|
@@ -32,6 +32,7 @@ def _handle(ui: Any, _: str) -> bool:
|
|
|
32
32
|
total_cache_read = usage.total_cache_read_tokens
|
|
33
33
|
total_cache_creation = usage.total_cache_creation_tokens
|
|
34
34
|
total_tokens = total_input + total_output + total_cache_read + total_cache_creation
|
|
35
|
+
total_cost = usage.total_cost_usd
|
|
35
36
|
|
|
36
37
|
ui.console.print("\n[bold]Session token usage[/bold]")
|
|
37
38
|
ui.console.print(
|
|
@@ -44,6 +45,8 @@ def _handle(ui: Any, _: str) -> bool:
|
|
|
44
45
|
f"{_fmt_tokens(total_cache_creation)} write"
|
|
45
46
|
)
|
|
46
47
|
ui.console.print(f" Requests: {usage.total_requests}")
|
|
48
|
+
if total_cost:
|
|
49
|
+
ui.console.print(f" Cost: ${total_cost:.4f}")
|
|
47
50
|
if usage.total_duration_ms:
|
|
48
51
|
ui.console.print(f" API time: {_format_duration(usage.total_duration_ms)}")
|
|
49
52
|
|
|
@@ -62,6 +65,8 @@ def _handle(ui: Any, _: str) -> bool:
|
|
|
62
65
|
if stats.duration_ms:
|
|
63
66
|
line += f", {_format_duration(stats.duration_ms)} total"
|
|
64
67
|
line += ")"
|
|
68
|
+
if stats.cost_usd:
|
|
69
|
+
line += f", ${stats.cost_usd:.4f}"
|
|
65
70
|
ui.console.print(line)
|
|
66
71
|
|
|
67
72
|
return True
|
|
@@ -0,0 +1,208 @@
|
|
|
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
|
+
|
|
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(project_path: Path) -> Tuple[List[Tuple[str, str, str]], List[str]]:
|
|
112
|
+
"""Return MCP status rows and errors."""
|
|
113
|
+
rows: List[Tuple[str, str, str]] = []
|
|
114
|
+
errors: List[str] = []
|
|
115
|
+
|
|
116
|
+
async def _load() -> List[Any]:
|
|
117
|
+
try:
|
|
118
|
+
return await load_mcp_servers_async(project_path)
|
|
119
|
+
finally:
|
|
120
|
+
await shutdown_mcp_runtime()
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
servers = asyncio.run(_load())
|
|
124
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
125
|
+
logger.exception("[doctor] Failed to load MCP servers", exc_info=exc)
|
|
126
|
+
rows.append(_status_row("MCP", "error", f"Failed to load MCP config: {exc}"))
|
|
127
|
+
return rows, errors
|
|
128
|
+
|
|
129
|
+
if not servers:
|
|
130
|
+
rows.append(_status_row("MCP", "warn", "No MCP servers configured (.mcp.json)"))
|
|
131
|
+
return rows, errors
|
|
132
|
+
|
|
133
|
+
failing = [s for s in servers if getattr(s, "error", None)]
|
|
134
|
+
rows.append(
|
|
135
|
+
_status_row(
|
|
136
|
+
"MCP",
|
|
137
|
+
"ok" if not failing else "warn",
|
|
138
|
+
f"{len(servers)} configured; {len(failing)} with errors",
|
|
139
|
+
)
|
|
140
|
+
)
|
|
141
|
+
for server in failing[:5]:
|
|
142
|
+
errors.append(f"{server.name}: {server.error}")
|
|
143
|
+
if len(failing) > 5:
|
|
144
|
+
errors.append(f"... {len(failing) - 5} more")
|
|
145
|
+
return rows, errors
|
|
146
|
+
|
|
147
|
+
|
|
148
|
+
def _project_status(project_path: Path) -> Tuple[str, str, str]:
|
|
149
|
+
try:
|
|
150
|
+
config = get_project_config(project_path)
|
|
151
|
+
# Access a field to ensure model parsing does not throw.
|
|
152
|
+
_ = len(config.allowed_tools)
|
|
153
|
+
return _status_row(
|
|
154
|
+
"Project config", "ok", f".ripperdoc/config.json loaded for {project_path}"
|
|
155
|
+
)
|
|
156
|
+
except Exception as exc: # pragma: no cover - defensive
|
|
157
|
+
logger.exception("[doctor] Failed to load project config", exc_info=exc)
|
|
158
|
+
return _status_row(
|
|
159
|
+
"Project config", "warn", f"Could not read .ripperdoc/config.json: {exc}"
|
|
160
|
+
)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
def _render_table(console: Any, rows: List[Tuple[str, str, str]]) -> None:
|
|
164
|
+
table = Table(show_header=True, header_style="bold cyan")
|
|
165
|
+
table.add_column("Check")
|
|
166
|
+
table.add_column("")
|
|
167
|
+
table.add_column("Details")
|
|
168
|
+
for label, status, detail in rows:
|
|
169
|
+
table.add_row(label, status, escape(detail) if detail else "")
|
|
170
|
+
console.print(table)
|
|
171
|
+
|
|
172
|
+
|
|
173
|
+
def _handle(ui: Any, _: str) -> bool:
|
|
174
|
+
project_path = getattr(ui, "project_path", Path.cwd())
|
|
175
|
+
results: List[Tuple[str, str, str]] = []
|
|
176
|
+
|
|
177
|
+
results.append(_onboarding_status())
|
|
178
|
+
results.extend(_model_status(project_path))
|
|
179
|
+
project_row = _project_status(project_path)
|
|
180
|
+
results.append(project_row)
|
|
181
|
+
|
|
182
|
+
mcp_rows, mcp_errors = _mcp_status(project_path)
|
|
183
|
+
results.extend(mcp_rows)
|
|
184
|
+
results.append(_sandbox_status())
|
|
185
|
+
|
|
186
|
+
ui.console.print(Panel("Environment diagnostics", title="/doctor", border_style="cyan"))
|
|
187
|
+
_render_table(ui.console, results)
|
|
188
|
+
|
|
189
|
+
if mcp_errors:
|
|
190
|
+
ui.console.print("\n[bold]MCP issues:[/bold]")
|
|
191
|
+
for err in mcp_errors:
|
|
192
|
+
ui.console.print(f" • {escape(err)}")
|
|
193
|
+
|
|
194
|
+
ui.console.print(
|
|
195
|
+
"\n[dim]If a check is failing, run `ripperdoc` without flags "
|
|
196
|
+
"to rerun onboarding or update ~/.ripperdoc.json[/dim]"
|
|
197
|
+
)
|
|
198
|
+
return True
|
|
199
|
+
|
|
200
|
+
|
|
201
|
+
command = SlashCommand(
|
|
202
|
+
name="doctor",
|
|
203
|
+
description="Diagnose model config, API keys, MCP, and sandbox support",
|
|
204
|
+
handler=_handle,
|
|
205
|
+
)
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
__all__ = ["command"]
|