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,386 @@
1
+ """Notebook edit tool.
2
+
3
+ Allows performing insert/replace/delete operations on Jupyter notebook cells.
4
+ """
5
+
6
+ import json
7
+ import os
8
+ import random
9
+ import string
10
+ from pathlib import Path
11
+ from textwrap import dedent
12
+ from typing import AsyncGenerator, List, Optional
13
+ from pydantic import BaseModel, Field
14
+
15
+ from ripperdoc.core.tool import (
16
+ Tool,
17
+ ToolUseContext,
18
+ ToolResult,
19
+ ToolOutput,
20
+ ToolUseExample,
21
+ ValidationResult,
22
+ )
23
+ from ripperdoc.utils.log import get_logger
24
+ from ripperdoc.utils.file_watch import record_snapshot
25
+
26
+
27
+ logger = get_logger()
28
+
29
+
30
+ def _resolve_path(path_str: str) -> Path:
31
+ """Return an absolute Path, interpreting relative paths from CWD."""
32
+ path = Path(path_str).expanduser()
33
+ return path if path.is_absolute() else Path.cwd() / path
34
+
35
+
36
+ def _generate_cell_id() -> str:
37
+ """Generate a short random cell id."""
38
+ return "".join(random.choices(string.ascii_lowercase + string.digits, k=12))
39
+
40
+
41
+ NOTEBOOK_EDIT_DESCRIPTION = dedent(
42
+ """\
43
+ Replace, insert, or delete a specific cell in a Jupyter notebook (.ipynb file).
44
+ notebook_path must be an absolute path. cell_id may be a 0-based index or a cell id.
45
+ Use edit_mode=insert to add a new cell after the referenced cell (or at the start if omitted).
46
+ Use edit_mode=delete to delete the referenced cell. Defaults to edit_mode=replace.
47
+
48
+ Usage:
49
+ - cell_type: 'code' or 'markdown'. Required for insert; defaults to existing type for replace.
50
+ - new_source: New content for the cell.
51
+ - Edits are applied atomically; failures leave the file unchanged.
52
+ - Code cell replacements clear execution_count and outputs.
53
+ - Only use emojis if explicitly requested; avoid adding emojis otherwise.
54
+ """
55
+ )
56
+
57
+
58
+ class NotebookEditInput(BaseModel):
59
+ """Input schema for NotebookEditTool."""
60
+
61
+ notebook_path: str = Field(description="Absolute path to the Jupyter notebook file to edit")
62
+ cell_id: Optional[str] = Field(
63
+ default=None,
64
+ description="Cell ID or 0-based index. For insert, omitted means insert at start.",
65
+ )
66
+ new_source: str = Field(description="New source content for the target cell")
67
+ cell_type: Optional[str] = Field(
68
+ default=None,
69
+ description="Cell type: 'code' or 'markdown'. Required for insert.",
70
+ )
71
+ edit_mode: Optional[str] = Field(
72
+ default="replace",
73
+ description="Edit mode: 'replace' (default), 'insert', or 'delete'.",
74
+ )
75
+
76
+
77
+ class NotebookEditOutput(BaseModel):
78
+ """Output from notebook editing."""
79
+
80
+ new_source: str
81
+ cell_id: Optional[str] = None
82
+ cell_type: str
83
+ language: str
84
+ edit_mode: str
85
+ error: Optional[str] = None
86
+
87
+
88
+ class NotebookEditTool(Tool[NotebookEditInput, NotebookEditOutput]):
89
+ """Tool for editing Jupyter notebooks."""
90
+
91
+ @property
92
+ def name(self) -> str:
93
+ return "NotebookEdit"
94
+
95
+ async def description(self) -> str:
96
+ return NOTEBOOK_EDIT_DESCRIPTION
97
+
98
+ @property
99
+ def input_schema(self) -> type[NotebookEditInput]:
100
+ return NotebookEditInput
101
+
102
+ def input_examples(self) -> List[ToolUseExample]:
103
+ return [
104
+ ToolUseExample(
105
+ description="Replace a markdown cell by id",
106
+ example={
107
+ "notebook_path": "/repo/notebooks/analysis.ipynb",
108
+ "cell_id": "abc123",
109
+ "new_source": "# Updated overview\\nThis notebook analyzes revenue.",
110
+ "cell_type": "markdown",
111
+ "edit_mode": "replace",
112
+ },
113
+ ),
114
+ ToolUseExample(
115
+ description="Insert a new code cell at the beginning",
116
+ example={
117
+ "notebook_path": "/repo/notebooks/analysis.ipynb",
118
+ "cell_type": "code",
119
+ "edit_mode": "insert",
120
+ "new_source": "import pandas as pd\\nimport numpy as np",
121
+ },
122
+ ),
123
+ ]
124
+
125
+ async def prompt(self, safe_mode: bool = False) -> str:
126
+ return NOTEBOOK_EDIT_DESCRIPTION
127
+
128
+ def is_read_only(self) -> bool:
129
+ return False
130
+
131
+ def is_concurrency_safe(self) -> bool:
132
+ return False
133
+
134
+ def needs_permissions(self, input_data: Optional[NotebookEditInput] = None) -> bool:
135
+ return True
136
+
137
+ async def validate_input(
138
+ self, input_data: NotebookEditInput, context: Optional[ToolUseContext] = None
139
+ ) -> ValidationResult:
140
+ path = _resolve_path(input_data.notebook_path)
141
+ resolved_path = str(path.resolve())
142
+
143
+ if not path.exists():
144
+ return ValidationResult(
145
+ result=False,
146
+ message="Notebook file does not exist.",
147
+ error_code=1,
148
+ )
149
+ if path.suffix != ".ipynb":
150
+ return ValidationResult(
151
+ result=False,
152
+ message="File must be a Jupyter notebook (.ipynb file). Use Edit for other file types.",
153
+ error_code=2,
154
+ )
155
+
156
+ mode = (input_data.edit_mode or "replace").lower()
157
+ if mode not in {"replace", "insert", "delete"}:
158
+ return ValidationResult(
159
+ result=False,
160
+ message="edit_mode must be replace, insert, or delete.",
161
+ error_code=3,
162
+ )
163
+ if mode == "insert" and not input_data.cell_type:
164
+ return ValidationResult(
165
+ result=False,
166
+ message="cell_type is required when using edit_mode=insert.",
167
+ error_code=4,
168
+ )
169
+ if mode != "insert" and not input_data.cell_id:
170
+ return ValidationResult(
171
+ result=False,
172
+ message="cell_id must be specified when using edit_mode=replace or delete.",
173
+ error_code=5,
174
+ )
175
+
176
+ # Check if file has been read before editing
177
+ file_state_cache = getattr(context, "file_state_cache", {}) if context else {}
178
+ file_snapshot = file_state_cache.get(resolved_path)
179
+
180
+ if not file_snapshot:
181
+ return ValidationResult(
182
+ result=False,
183
+ message="Notebook has not been read yet. Read it first before editing.",
184
+ error_code=6,
185
+ )
186
+
187
+ # Check if file has been modified since it was read
188
+ try:
189
+ current_mtime = os.path.getmtime(resolved_path)
190
+ if current_mtime > file_snapshot.timestamp:
191
+ return ValidationResult(
192
+ result=False,
193
+ message="Notebook has been modified since read, either by the user or by a linter. "
194
+ "Read it again before attempting to edit it.",
195
+ error_code=7,
196
+ )
197
+ except OSError:
198
+ pass # File mtime check failed, proceed anyway
199
+
200
+ # Validate notebook structure and target cell.
201
+ try:
202
+ raw = path.read_text(encoding="utf-8")
203
+ nb_json = json.loads(raw)
204
+ except (OSError, json.JSONDecodeError, UnicodeDecodeError) as exc:
205
+ logger.warning(
206
+ "Failed to parse notebook: %s: %s",
207
+ type(exc).__name__, exc,
208
+ extra={"path": str(path)},
209
+ )
210
+ return ValidationResult(
211
+ result=False,
212
+ message="Notebook is not valid JSON.",
213
+ error_code=8,
214
+ )
215
+
216
+ cells = nb_json.get("cells", [])
217
+ target_index, _ = self._resolve_cell_index(cells, input_data.cell_id, mode)
218
+ if target_index is None:
219
+ if mode == "insert" and input_data.cell_id is None:
220
+ return ValidationResult(result=True)
221
+ return ValidationResult(
222
+ result=False,
223
+ message=f"Cell '{input_data.cell_id}' not found in notebook.",
224
+ error_code=9,
225
+ )
226
+
227
+ return ValidationResult(result=True)
228
+
229
+ def render_result_for_assistant(self, output: NotebookEditOutput) -> str:
230
+ if output.error:
231
+ return output.error
232
+ action = output.edit_mode or "replace"
233
+ cell_label = output.cell_id or "(new cell)"
234
+ if action == "delete":
235
+ return f"Deleted cell {cell_label}"
236
+ if action == "insert":
237
+ return f"Inserted cell {cell_label}"
238
+ return f"Updated cell {cell_label}"
239
+
240
+ def render_tool_use_message(self, input_data: NotebookEditInput, verbose: bool = False) -> str:
241
+ parts = [f"path: {input_data.notebook_path}"]
242
+ if input_data.cell_id:
243
+ parts.append(f"cell_id: {input_data.cell_id}")
244
+ if verbose:
245
+ parts.append(f"mode: {input_data.edit_mode or 'replace'}")
246
+ return ", ".join(parts)
247
+
248
+ async def call(
249
+ self, input_data: NotebookEditInput, context: ToolUseContext
250
+ ) -> AsyncGenerator[ToolOutput, None]:
251
+ path = _resolve_path(input_data.notebook_path)
252
+ mode = (input_data.edit_mode or "replace").lower()
253
+ cell_type = (input_data.cell_type or "").lower() or None
254
+ new_source = input_data.new_source
255
+ cell_id = input_data.cell_id
256
+
257
+ try:
258
+ raw = path.read_text(encoding="utf-8")
259
+ nb_json = json.loads(raw)
260
+ cells = nb_json.get("cells", [])
261
+
262
+ target_index, matched_id = self._resolve_cell_index(cells, cell_id, mode)
263
+
264
+ final_mode = mode
265
+ if final_mode == "replace" and target_index is not None and target_index == len(cells):
266
+ final_mode = "insert"
267
+ if not cell_type:
268
+ cell_type = "code"
269
+
270
+ if final_mode == "delete":
271
+ if target_index is None:
272
+ raise ValueError("Target cell not found for delete.")
273
+ cells.pop(target_index)
274
+ elif final_mode == "insert":
275
+ insert_at = target_index if target_index is not None else 0
276
+ new_id = matched_id or _generate_cell_id()
277
+ new_cell_type = cell_type or "code"
278
+ new_cell = (
279
+ {
280
+ "cell_type": "markdown",
281
+ "id": new_id,
282
+ "source": new_source,
283
+ "metadata": {},
284
+ }
285
+ if new_cell_type == "markdown"
286
+ else {
287
+ "cell_type": "code",
288
+ "id": new_id,
289
+ "source": new_source,
290
+ "metadata": {},
291
+ "execution_count": None, # type: ignore[dict-item]
292
+ "outputs": [],
293
+ }
294
+ )
295
+ cells.insert(insert_at, new_cell)
296
+ matched_id = new_id
297
+ cell_type = new_cell_type
298
+ else: # replace
299
+ if target_index is None:
300
+ raise ValueError("Target cell not found for replace.")
301
+ target_cell = cells[target_index]
302
+ target_cell["source"] = new_source
303
+ if target_cell.get("cell_type") == "code":
304
+ target_cell["execution_count"] = None
305
+ target_cell["outputs"] = []
306
+ if cell_type and cell_type != target_cell.get("cell_type"):
307
+ target_cell["cell_type"] = cell_type
308
+ matched_id = target_cell.get("id") or matched_id
309
+ cell_type = target_cell.get("cell_type", cell_type or "code")
310
+
311
+ nb_json["cells"] = cells
312
+ notebook_language = (
313
+ nb_json.get("metadata", {}).get("language_info", {}).get("name", "python")
314
+ )
315
+
316
+ path.write_text(json.dumps(nb_json, indent=1), encoding="utf-8")
317
+ # Use resolved absolute path to ensure consistency with validation lookup
318
+ abs_notebook_path = str(path.resolve())
319
+ try:
320
+ record_snapshot(
321
+ abs_notebook_path,
322
+ json.dumps(nb_json, indent=1),
323
+ getattr(context, "file_state_cache", {}),
324
+ )
325
+ except (OSError, IOError, RuntimeError) as exc:
326
+ logger.warning(
327
+ "[notebook_edit_tool] Failed to record file snapshot: %s: %s",
328
+ type(exc).__name__, exc,
329
+ extra={"file_path": abs_notebook_path},
330
+ )
331
+
332
+ output = NotebookEditOutput(
333
+ new_source=new_source,
334
+ cell_type=cell_type or "code",
335
+ language=notebook_language,
336
+ edit_mode=final_mode,
337
+ cell_id=matched_id,
338
+ error=None,
339
+ )
340
+ yield ToolResult(
341
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
342
+ )
343
+ except (OSError, json.JSONDecodeError, ValueError, KeyError) as exc:
344
+ # pragma: no cover - error path
345
+ logger.warning(
346
+ "Error editing notebook: %s: %s",
347
+ type(exc).__name__, exc,
348
+ extra={"path": input_data.notebook_path},
349
+ )
350
+ output = NotebookEditOutput(
351
+ new_source=new_source,
352
+ cell_type=cell_type or "code",
353
+ language="python",
354
+ edit_mode=mode,
355
+ cell_id=cell_id,
356
+ error=str(exc),
357
+ )
358
+ yield ToolResult(
359
+ data=output, result_for_assistant=self.render_result_for_assistant(output)
360
+ )
361
+
362
+ def _resolve_cell_index(
363
+ self, cells: list, cell_id: Optional[str], mode: str
364
+ ) -> tuple[Optional[int], Optional[str]]:
365
+ """Return target index and resolved id."""
366
+ if cell_id is None:
367
+ return (0 if mode == "insert" else None, None)
368
+
369
+ # Try numeric index first.
370
+ try:
371
+ idx = int(cell_id)
372
+ if mode == "insert":
373
+ if idx < 0 or idx > len(cells):
374
+ return None, None
375
+ return min(idx + 1, len(cells)), None
376
+ else:
377
+ if idx < 0 or idx >= len(cells):
378
+ return None, None
379
+ return idx, None
380
+ except (ValueError, TypeError):
381
+ pass
382
+
383
+ for i, cell in enumerate(cells):
384
+ if cell.get("id") == cell_id:
385
+ return (i if mode != "insert" else i + 1, cell.get("id"))
386
+ return None, None
@@ -0,0 +1,205 @@
1
+ """Skill loader tool.
2
+
3
+ Loads SKILL.md content from .ripperdoc/skills or ~/.ripperdoc/skills so the
4
+ assistant can pull in specialized instructions only when needed.
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ from pathlib import Path
10
+ from typing import AsyncGenerator, List, Optional
11
+
12
+ from pydantic import BaseModel, Field
13
+
14
+ from ripperdoc.core.skills import SkillDefinition, find_skill
15
+ from ripperdoc.core.tool import (
16
+ Tool,
17
+ ToolOutput,
18
+ ToolResult,
19
+ ToolUseContext,
20
+ ToolUseExample,
21
+ ValidationResult,
22
+ )
23
+ from ripperdoc.utils.log import get_logger
24
+
25
+ logger = get_logger()
26
+
27
+
28
+ class SkillToolInput(BaseModel):
29
+ """Input schema for the Skill tool."""
30
+
31
+ skill: str = Field(description='The skill name (e.g. "pdf-processing").')
32
+
33
+
34
+ class SkillToolOutput(BaseModel):
35
+ """Structured output for a loaded skill."""
36
+
37
+ success: bool = True
38
+ skill: str
39
+ description: str
40
+ location: str
41
+ base_dir: str
42
+ path: str
43
+ allowed_tools: List[str] = Field(default_factory=list)
44
+ model: Optional[str] = None
45
+ max_thinking_tokens: Optional[int] = None
46
+ skill_type: str = "prompt"
47
+ disable_model_invocation: bool = False
48
+ content: str
49
+
50
+
51
+ class SkillTool(Tool[SkillToolInput, SkillToolOutput]):
52
+ """Load a skill's instructions by name."""
53
+
54
+ def __init__(self, project_path: Optional[Path] = None, home: Optional[Path] = None) -> None:
55
+ self._project_path = project_path
56
+ self._home = home
57
+
58
+ @property
59
+ def name(self) -> str:
60
+ return "Skill"
61
+
62
+ async def description(self) -> str:
63
+ return (
64
+ "Execute a skill by name to load its SKILL.md instructions. "
65
+ "Use this only when the skill description clearly matches the user's request. "
66
+ "Skill metadata may include allowed-tools, model, or max-thinking-tokens hints."
67
+ )
68
+
69
+ @property
70
+ def input_schema(self) -> type[SkillToolInput]:
71
+ return SkillToolInput
72
+
73
+ def input_examples(self) -> List[ToolUseExample]:
74
+ return [
75
+ ToolUseExample(
76
+ description="Load PDF processing guidance",
77
+ example={"skill": "pdf-processing"},
78
+ ),
79
+ ToolUseExample(
80
+ description="Load commit message helper instructions",
81
+ example={"skill": "generating-commit-messages"},
82
+ ),
83
+ ]
84
+
85
+ async def prompt(self, safe_mode: bool = False) -> str: # noqa: ARG002
86
+ return (
87
+ "Load a skill by name to read its SKILL.md content. "
88
+ "Only call this when the skill description is clearly relevant. "
89
+ "If the skill specifies allowed-tools, model, or max-thinking-tokens in frontmatter, "
90
+ "assume those hints apply for subsequent reasoning. "
91
+ "Skill files may reference additional files under the same directory; "
92
+ "use file tools to read them if needed."
93
+ )
94
+
95
+ def is_read_only(self) -> bool:
96
+ return True
97
+
98
+ def is_concurrency_safe(self) -> bool:
99
+ return False
100
+
101
+ def needs_permissions(self, input_data: Optional[SkillToolInput] = None) -> bool: # noqa: ARG002
102
+ return False
103
+
104
+ async def validate_input(
105
+ self,
106
+ input_data: SkillToolInput,
107
+ context: Optional[ToolUseContext] = None, # noqa: ARG002
108
+ ) -> ValidationResult:
109
+ skill_name = (input_data.skill or "").strip().lstrip("/")
110
+ if not skill_name:
111
+ return ValidationResult(
112
+ result=False, message="Provide a skill name to load.", error_code=1
113
+ )
114
+ skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
115
+ if not skill:
116
+ return ValidationResult(
117
+ result=False, message=f"Unknown skill: {skill_name}", error_code=2
118
+ )
119
+ if skill.disable_model_invocation:
120
+ return ValidationResult(
121
+ result=False,
122
+ message=f"Skill {skill_name} is blocked by disable-model-invocation.",
123
+ error_code=4,
124
+ )
125
+ if skill.skill_type and skill.skill_type != "prompt":
126
+ return ValidationResult(
127
+ result=False,
128
+ message=f"Skill {skill_name} is not a prompt-based skill (type={skill.skill_type}).",
129
+ error_code=5,
130
+ meta={"skill_type": skill.skill_type},
131
+ )
132
+ return ValidationResult(result=True)
133
+
134
+ def _render_result(self, skill: SkillDefinition) -> str:
135
+ allowed = ", ".join(skill.allowed_tools) if skill.allowed_tools else "no specific limit"
136
+ model_hint = f"\nModel hint: {skill.model}" if skill.model else ""
137
+ max_tokens = (
138
+ f"\nMax thinking tokens hint: {skill.max_thinking_tokens}"
139
+ if skill.max_thinking_tokens is not None
140
+ else ""
141
+ )
142
+ lines = [
143
+ f"Skill loaded: {skill.name} ({skill.location.value})",
144
+ f"Description: {skill.description}",
145
+ f"Skill directory: {skill.base_dir}",
146
+ f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}",
147
+ "SKILL.md content:",
148
+ skill.content,
149
+ ]
150
+ return "\n".join(lines)
151
+
152
+ def _to_output(self, skill: SkillDefinition) -> SkillToolOutput:
153
+ return SkillToolOutput(
154
+ success=True,
155
+ skill=skill.name,
156
+ description=skill.description,
157
+ location=skill.location.value,
158
+ base_dir=str(skill.base_dir),
159
+ path=str(skill.path),
160
+ allowed_tools=list(skill.allowed_tools),
161
+ model=skill.model,
162
+ max_thinking_tokens=skill.max_thinking_tokens,
163
+ skill_type=skill.skill_type,
164
+ disable_model_invocation=skill.disable_model_invocation,
165
+ content=skill.content,
166
+ )
167
+
168
+ async def call(
169
+ self, input_data: SkillToolInput, context: ToolUseContext
170
+ ) -> AsyncGenerator[ToolOutput, None]: # noqa: ARG002
171
+ skill_name = (input_data.skill or "").strip().lstrip("/")
172
+ skill = find_skill(skill_name, project_path=self._project_path, home=self._home)
173
+ if not skill:
174
+ error_text = (
175
+ f"Skill '{skill_name}' not found. Ensure it exists under "
176
+ "~/.ripperdoc/skills or ./.ripperdoc/skills."
177
+ )
178
+ yield ToolResult(data={"error": error_text}, result_for_assistant=error_text)
179
+ return
180
+ if skill.allowed_tools and context.tool_registry is not None:
181
+ # Ensure preferred tools for this skill are activated in the registry.
182
+ context.tool_registry.activate_tools(skill.allowed_tools)
183
+
184
+ output = self._to_output(skill)
185
+ yield ToolResult(data=output, result_for_assistant=self._render_result(skill))
186
+
187
+ def render_result_for_assistant(self, output: SkillToolOutput) -> str:
188
+ allowed = ", ".join(output.allowed_tools) if output.allowed_tools else "no specific limit"
189
+ model_hint = f"\nModel hint: {output.model}" if output.model else ""
190
+ max_tokens = (
191
+ f"\nMax thinking tokens hint: {output.max_thinking_tokens}"
192
+ if output.max_thinking_tokens is not None
193
+ else ""
194
+ )
195
+ return (
196
+ f"Skill loaded: {output.skill} ({output.location})\n"
197
+ f"Description: {output.description}\n"
198
+ f"Skill directory: {output.base_dir}\n"
199
+ f"Allowed tools (if specified): {allowed}{model_hint}{max_tokens}\n"
200
+ "SKILL.md content:\n"
201
+ f"{output.content}"
202
+ )
203
+
204
+ def render_tool_use_message(self, input_data: SkillToolInput, verbose: bool = False) -> str: # noqa: ARG002
205
+ return f"Load skill '{input_data.skill}'"