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,295 @@
1
+ """Agent Skill loading helpers for Ripperdoc.
2
+
3
+ Skills are small capability bundles defined by SKILL.md files that live under
4
+ `~/.ripperdoc/skills` or `.ripperdoc/skills` in a project. Only the skill
5
+ metadata (name + description) should be added to the system prompt up front;
6
+ the full content is loaded on demand via the Skill tool. Optional frontmatter
7
+ fields include:
8
+ - allowed-tools: Comma-separated list of tools that are allowed/preferred.
9
+ - model: Model pointer hint for this skill.
10
+ - max-thinking-tokens: Reasoning budget hint for this skill.
11
+ - disable-model-invocation: If true, block the Skill tool from loading this
12
+ skill.
13
+ - type: Skill kind (defaults to "prompt").
14
+ """
15
+
16
+ from __future__ import annotations
17
+
18
+ import re
19
+ from dataclasses import dataclass
20
+ from enum import Enum
21
+ from pathlib import Path
22
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
23
+
24
+ import yaml
25
+
26
+ from ripperdoc.utils.coerce import parse_boolish, parse_optional_int
27
+ from ripperdoc.utils.log import get_logger
28
+
29
+ logger = get_logger()
30
+
31
+ SKILL_DIR_NAME = "skills"
32
+ SKILL_FILE_NAME = "SKILL.md"
33
+ _SKILL_NAME_RE = re.compile(r"^[a-z0-9-]{1,64}$")
34
+
35
+
36
+ class SkillLocation(str, Enum):
37
+ """Where a skill definition is sourced from."""
38
+
39
+ USER = "user"
40
+ PROJECT = "project"
41
+ OTHER = "other"
42
+
43
+
44
+ @dataclass
45
+ class SkillDefinition:
46
+ """Parsed representation of a skill."""
47
+
48
+ name: str
49
+ description: str
50
+ content: str
51
+ path: Path
52
+ base_dir: Path
53
+ location: SkillLocation
54
+ allowed_tools: List[str]
55
+ model: Optional[str] = None
56
+ max_thinking_tokens: Optional[int] = None
57
+ skill_type: str = "prompt"
58
+ disable_model_invocation: bool = False
59
+
60
+
61
+ @dataclass
62
+ class SkillLoadError:
63
+ """Error encountered while loading a skill file."""
64
+
65
+ path: Path
66
+ reason: str
67
+
68
+
69
+ @dataclass
70
+ class SkillLoadResult:
71
+ """Aggregated result of loading skills."""
72
+
73
+ skills: List[SkillDefinition]
74
+ errors: List[SkillLoadError]
75
+
76
+
77
+ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
78
+ """Extract YAML frontmatter and body content from a markdown file."""
79
+ lines = raw_text.splitlines()
80
+ if len(lines) >= 3 and lines[0].strip() == "---":
81
+ for idx in range(1, len(lines)):
82
+ if lines[idx].strip() == "---":
83
+ frontmatter_text = "\n".join(lines[1:idx])
84
+ body = "\n".join(lines[idx + 1 :])
85
+ try:
86
+ frontmatter = yaml.safe_load(frontmatter_text) or {}
87
+ except (yaml.YAMLError, ValueError, TypeError) as exc: # pragma: no cover - defensive
88
+ logger.warning(
89
+ "[skills] Invalid frontmatter in SKILL.md: %s: %s",
90
+ type(exc).__name__, exc,
91
+ )
92
+ return {"__error__": f"Invalid frontmatter: {exc}"}, body
93
+ return frontmatter, body
94
+ return {}, raw_text
95
+
96
+
97
+ def _normalize_allowed_tools(value: object) -> List[str]:
98
+ """Normalize allowed-tools values to a clean list of tool names."""
99
+ if value is None:
100
+ return []
101
+ if isinstance(value, str):
102
+ return [item.strip() for item in value.split(",") if item.strip()]
103
+ if isinstance(value, Iterable):
104
+ tools: List[str] = []
105
+ for item in value:
106
+ if isinstance(item, str) and item.strip():
107
+ tools.append(item.strip())
108
+ return tools
109
+ return []
110
+
111
+
112
+ def _load_skill_file(
113
+ path: Path, location: SkillLocation
114
+ ) -> Tuple[Optional[SkillDefinition], Optional[SkillLoadError]]:
115
+ """Parse a single SKILL.md file."""
116
+ try:
117
+ text = path.read_text(encoding="utf-8")
118
+ except (OSError, IOError, UnicodeDecodeError) as exc:
119
+ logger.warning(
120
+ "[skills] Failed to read skill file: %s: %s",
121
+ type(exc).__name__, exc,
122
+ extra={"path": str(path)},
123
+ )
124
+ return None, SkillLoadError(path=path, reason=f"Failed to read file: {exc}")
125
+
126
+ frontmatter, body = _split_frontmatter(text)
127
+ if "__error__" in frontmatter:
128
+ return None, SkillLoadError(path=path, reason=str(frontmatter["__error__"]))
129
+
130
+ raw_name = frontmatter.get("name")
131
+ raw_description = frontmatter.get("description")
132
+ if not isinstance(raw_name, str) or not raw_name.strip():
133
+ return None, SkillLoadError(path=path, reason='Missing required "name" field')
134
+ if not _SKILL_NAME_RE.match(raw_name.strip()):
135
+ return None, SkillLoadError(
136
+ path=path,
137
+ reason='Invalid "name" format. Use lowercase letters, numbers, and hyphens only (max 64 chars).',
138
+ )
139
+ if not isinstance(raw_description, str) or not raw_description.strip():
140
+ return None, SkillLoadError(path=path, reason='Missing required "description" field')
141
+
142
+ allowed_tools = _normalize_allowed_tools(
143
+ frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
144
+ )
145
+ model_value = frontmatter.get("model")
146
+ model = model_value if isinstance(model_value, str) and model_value.strip() else None
147
+ max_thinking_tokens = parse_optional_int(
148
+ frontmatter.get("max-thinking-tokens") or frontmatter.get("max_thinking_tokens")
149
+ )
150
+ raw_type = (
151
+ frontmatter.get("type")
152
+ or frontmatter.get("skill-type")
153
+ or frontmatter.get("skill_type")
154
+ or "prompt"
155
+ )
156
+ skill_type = str(raw_type).strip().lower() if isinstance(raw_type, str) else "prompt"
157
+ disable_model_invocation = parse_boolish(
158
+ frontmatter.get("disable-model-invocation") or frontmatter.get("disable_model_invocation")
159
+ )
160
+
161
+ skill = SkillDefinition(
162
+ name=raw_name.strip(),
163
+ description=raw_description.strip(),
164
+ content=body.strip(),
165
+ path=path,
166
+ base_dir=path.parent,
167
+ location=location,
168
+ allowed_tools=allowed_tools,
169
+ model=model,
170
+ max_thinking_tokens=max_thinking_tokens,
171
+ skill_type=skill_type or "prompt",
172
+ disable_model_invocation=disable_model_invocation,
173
+ )
174
+ return skill, None
175
+
176
+
177
+ def _load_skill_dir(
178
+ path: Path, location: SkillLocation
179
+ ) -> Tuple[List[SkillDefinition], List[SkillLoadError]]:
180
+ """Load skills from a directory that either contains SKILL.md or subdirectories."""
181
+ skills: List[SkillDefinition] = []
182
+ errors: List[SkillLoadError] = []
183
+ if not path.exists() or not path.is_dir():
184
+ return skills, errors
185
+
186
+ single_skill = path / SKILL_FILE_NAME
187
+ if single_skill.exists():
188
+ skill, error = _load_skill_file(single_skill, location)
189
+ if skill:
190
+ skills.append(skill)
191
+ elif error:
192
+ errors.append(error)
193
+ return skills, errors
194
+
195
+ for entry in sorted(path.iterdir()):
196
+ try:
197
+ if not entry.is_dir() and not entry.is_symlink():
198
+ continue
199
+ except OSError:
200
+ continue
201
+
202
+ candidate = entry / SKILL_FILE_NAME
203
+ if not candidate.exists():
204
+ continue
205
+ skill, error = _load_skill_file(candidate, location)
206
+ if skill:
207
+ skills.append(skill)
208
+ elif error:
209
+ errors.append(error)
210
+ return skills, errors
211
+
212
+
213
+ def skill_directories(
214
+ project_path: Optional[Path] = None, home: Optional[Path] = None
215
+ ) -> List[Tuple[Path, SkillLocation]]:
216
+ """Return the standard skill directories for user and project scopes."""
217
+ home_dir = (home or Path.home()).expanduser()
218
+ project_dir = (project_path or Path.cwd()).resolve()
219
+ return [
220
+ (home_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.USER),
221
+ (project_dir / ".ripperdoc" / SKILL_DIR_NAME, SkillLocation.PROJECT),
222
+ ]
223
+
224
+
225
+ def load_all_skills(
226
+ project_path: Optional[Path] = None, home: Optional[Path] = None
227
+ ) -> SkillLoadResult:
228
+ """Load skills from user and project directories.
229
+
230
+ Project skills override user skills with the same name.
231
+ """
232
+ skills_by_name: Dict[str, SkillDefinition] = {}
233
+ errors: List[SkillLoadError] = []
234
+
235
+ # Load user first so project overrides take precedence.
236
+ for directory, location in skill_directories(project_path=project_path, home=home):
237
+ loaded, dir_errors = _load_skill_dir(directory, location)
238
+ errors.extend(dir_errors)
239
+ for skill in loaded:
240
+ if skill.name in skills_by_name:
241
+ logger.debug(
242
+ "[skills] Overriding skill",
243
+ extra={
244
+ "skill_name": skill.name,
245
+ "previous_location": str(skills_by_name[skill.name].location),
246
+ "new_location": str(location),
247
+ },
248
+ )
249
+ skills_by_name[skill.name] = skill
250
+ return SkillLoadResult(skills=list(skills_by_name.values()), errors=errors)
251
+
252
+
253
+ def find_skill(
254
+ skill_name: str, project_path: Optional[Path] = None, home: Optional[Path] = None
255
+ ) -> Optional[SkillDefinition]:
256
+ """Find a skill by name (case-sensitive match)."""
257
+ normalized = skill_name.strip().lstrip("/")
258
+ if not normalized:
259
+ return None
260
+ result = load_all_skills(project_path=project_path, home=home)
261
+ return next((skill for skill in result.skills if skill.name == normalized), None)
262
+
263
+
264
+ def build_skill_summary(skills: Sequence[SkillDefinition]) -> str:
265
+ """Render a concise instruction block listing available skills."""
266
+ if not skills:
267
+ return (
268
+ "# Skills\n"
269
+ "No skills detected. Add SKILL.md under ~/.ripperdoc/skills or ./.ripperdoc/skills "
270
+ "to extend capabilities, then load them with the Skill tool when relevant."
271
+ )
272
+ lines = [
273
+ "# Skills",
274
+ "Skills extend your capabilities with reusable instructions stored in SKILL.md files.",
275
+ 'Call the Skill tool with {"skill": "<name>"} to load a skill when it matches the user request.',
276
+ "Available skills:",
277
+ ]
278
+ for skill in skills:
279
+ location = f" ({skill.location.value})" if skill.location else ""
280
+ lines.append(f"- {skill.name}{location}: {skill.description}")
281
+ return "\n".join(lines)
282
+
283
+
284
+ __all__ = [
285
+ "SkillDefinition",
286
+ "SkillLoadError",
287
+ "SkillLoadResult",
288
+ "SkillLocation",
289
+ "SKILL_DIR_NAME",
290
+ "SKILL_FILE_NAME",
291
+ "load_all_skills",
292
+ "find_skill",
293
+ "build_skill_summary",
294
+ "skill_directories",
295
+ ]