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.
- ripperdoc/__init__.py +3 -0
- ripperdoc/__main__.py +20 -0
- ripperdoc/cli/__init__.py +1 -0
- ripperdoc/cli/cli.py +405 -0
- ripperdoc/cli/commands/__init__.py +82 -0
- ripperdoc/cli/commands/agents_cmd.py +263 -0
- ripperdoc/cli/commands/base.py +19 -0
- ripperdoc/cli/commands/clear_cmd.py +18 -0
- ripperdoc/cli/commands/compact_cmd.py +23 -0
- ripperdoc/cli/commands/config_cmd.py +31 -0
- ripperdoc/cli/commands/context_cmd.py +144 -0
- ripperdoc/cli/commands/cost_cmd.py +82 -0
- ripperdoc/cli/commands/doctor_cmd.py +221 -0
- ripperdoc/cli/commands/exit_cmd.py +19 -0
- ripperdoc/cli/commands/help_cmd.py +20 -0
- ripperdoc/cli/commands/mcp_cmd.py +70 -0
- ripperdoc/cli/commands/memory_cmd.py +202 -0
- ripperdoc/cli/commands/models_cmd.py +413 -0
- ripperdoc/cli/commands/permissions_cmd.py +302 -0
- ripperdoc/cli/commands/resume_cmd.py +98 -0
- ripperdoc/cli/commands/status_cmd.py +167 -0
- ripperdoc/cli/commands/tasks_cmd.py +278 -0
- ripperdoc/cli/commands/todos_cmd.py +69 -0
- ripperdoc/cli/commands/tools_cmd.py +19 -0
- ripperdoc/cli/ui/__init__.py +1 -0
- ripperdoc/cli/ui/context_display.py +298 -0
- ripperdoc/cli/ui/helpers.py +22 -0
- ripperdoc/cli/ui/rich_ui.py +1557 -0
- ripperdoc/cli/ui/spinner.py +49 -0
- ripperdoc/cli/ui/thinking_spinner.py +128 -0
- ripperdoc/cli/ui/tool_renderers.py +298 -0
- ripperdoc/core/__init__.py +1 -0
- ripperdoc/core/agents.py +486 -0
- ripperdoc/core/commands.py +33 -0
- ripperdoc/core/config.py +559 -0
- ripperdoc/core/default_tools.py +88 -0
- ripperdoc/core/permissions.py +252 -0
- ripperdoc/core/providers/__init__.py +47 -0
- ripperdoc/core/providers/anthropic.py +250 -0
- ripperdoc/core/providers/base.py +265 -0
- ripperdoc/core/providers/gemini.py +615 -0
- ripperdoc/core/providers/openai.py +487 -0
- ripperdoc/core/query.py +1058 -0
- ripperdoc/core/query_utils.py +622 -0
- ripperdoc/core/skills.py +295 -0
- ripperdoc/core/system_prompt.py +431 -0
- ripperdoc/core/tool.py +240 -0
- ripperdoc/sdk/__init__.py +9 -0
- ripperdoc/sdk/client.py +333 -0
- ripperdoc/tools/__init__.py +1 -0
- ripperdoc/tools/ask_user_question_tool.py +431 -0
- ripperdoc/tools/background_shell.py +389 -0
- ripperdoc/tools/bash_output_tool.py +98 -0
- ripperdoc/tools/bash_tool.py +1016 -0
- ripperdoc/tools/dynamic_mcp_tool.py +428 -0
- ripperdoc/tools/enter_plan_mode_tool.py +226 -0
- ripperdoc/tools/exit_plan_mode_tool.py +153 -0
- ripperdoc/tools/file_edit_tool.py +346 -0
- ripperdoc/tools/file_read_tool.py +203 -0
- ripperdoc/tools/file_write_tool.py +205 -0
- ripperdoc/tools/glob_tool.py +179 -0
- ripperdoc/tools/grep_tool.py +370 -0
- ripperdoc/tools/kill_bash_tool.py +136 -0
- ripperdoc/tools/ls_tool.py +471 -0
- ripperdoc/tools/mcp_tools.py +591 -0
- ripperdoc/tools/multi_edit_tool.py +456 -0
- ripperdoc/tools/notebook_edit_tool.py +386 -0
- ripperdoc/tools/skill_tool.py +205 -0
- ripperdoc/tools/task_tool.py +379 -0
- ripperdoc/tools/todo_tool.py +494 -0
- ripperdoc/tools/tool_search_tool.py +380 -0
- ripperdoc/utils/__init__.py +1 -0
- ripperdoc/utils/bash_constants.py +51 -0
- ripperdoc/utils/bash_output_utils.py +43 -0
- ripperdoc/utils/coerce.py +34 -0
- ripperdoc/utils/context_length_errors.py +252 -0
- ripperdoc/utils/exit_code_handlers.py +241 -0
- ripperdoc/utils/file_watch.py +135 -0
- ripperdoc/utils/git_utils.py +274 -0
- ripperdoc/utils/json_utils.py +27 -0
- ripperdoc/utils/log.py +176 -0
- ripperdoc/utils/mcp.py +560 -0
- ripperdoc/utils/memory.py +253 -0
- ripperdoc/utils/message_compaction.py +676 -0
- ripperdoc/utils/messages.py +519 -0
- ripperdoc/utils/output_utils.py +258 -0
- ripperdoc/utils/path_ignore.py +677 -0
- ripperdoc/utils/path_utils.py +46 -0
- ripperdoc/utils/permissions/__init__.py +27 -0
- ripperdoc/utils/permissions/path_validation_utils.py +174 -0
- ripperdoc/utils/permissions/shell_command_validation.py +552 -0
- ripperdoc/utils/permissions/tool_permission_utils.py +279 -0
- ripperdoc/utils/prompt.py +17 -0
- ripperdoc/utils/safe_get_cwd.py +31 -0
- ripperdoc/utils/sandbox_utils.py +38 -0
- ripperdoc/utils/session_history.py +260 -0
- ripperdoc/utils/session_usage.py +117 -0
- ripperdoc/utils/shell_token_utils.py +95 -0
- ripperdoc/utils/shell_utils.py +159 -0
- ripperdoc/utils/todo.py +203 -0
- ripperdoc/utils/token_estimation.py +34 -0
- ripperdoc-0.2.6.dist-info/METADATA +193 -0
- ripperdoc-0.2.6.dist-info/RECORD +107 -0
- ripperdoc-0.2.6.dist-info/WHEEL +5 -0
- ripperdoc-0.2.6.dist-info/entry_points.txt +3 -0
- ripperdoc-0.2.6.dist-info/licenses/LICENSE +53 -0
- ripperdoc-0.2.6.dist-info/top_level.txt +1 -0
ripperdoc/core/skills.py
ADDED
|
@@ -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
|
+
]
|