ripperdoc 0.2.7__py3-none-any.whl → 0.2.8__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.
@@ -0,0 +1,411 @@
1
+ """Custom Command loading and execution for Ripperdoc.
2
+
3
+ Custom commands are defined in .md files under:
4
+ - `~/.ripperdoc/commands/` (user-level commands)
5
+ - `.ripperdoc/commands/` (project-level commands)
6
+
7
+ Features:
8
+ - Frontmatter support (YAML) for metadata: allowed-tools, description, argument-hint, model, thinking-mode
9
+ - Parameter substitution: $ARGUMENTS, $1, $2, etc.
10
+ - File references: @filename (resolved relative to project root)
11
+ - Bash command execution: !`command` syntax
12
+ - Nested commands via subdirectories (e.g., git/commit.md -> /git:commit)
13
+ """
14
+
15
+ from __future__ import annotations
16
+
17
+ import asyncio
18
+ import re
19
+ import subprocess
20
+ from dataclasses import dataclass, field
21
+ from enum import Enum
22
+ from pathlib import Path
23
+ from typing import Any, Dict, Iterable, List, Optional, Sequence, Tuple
24
+
25
+ import yaml
26
+
27
+ from ripperdoc.utils.coerce import parse_boolish, parse_optional_int
28
+ from ripperdoc.utils.log import get_logger
29
+
30
+ logger = get_logger()
31
+
32
+ COMMAND_DIR_NAME = "commands"
33
+ COMMAND_FILE_SUFFIX = ".md"
34
+ _COMMAND_NAME_RE = re.compile(r"^[a-z0-9_-]{1,64}$")
35
+
36
+ # Pattern for bash command execution: !`command`
37
+ _BASH_COMMAND_PATTERN = re.compile(r"!\`([^`]+)\`")
38
+
39
+ # Pattern for file references: @filename
40
+ _FILE_REFERENCE_PATTERN = re.compile(r"@([^\s\]]+)")
41
+
42
+ # Pattern for positional arguments: $1, $2, etc.
43
+ _POSITIONAL_ARG_PATTERN = re.compile(r"\$(\d+)")
44
+
45
+ # Pattern for all arguments: $ARGUMENTS
46
+ _ALL_ARGS_PATTERN = re.compile(r"\$ARGUMENTS", re.IGNORECASE)
47
+
48
+ # Thinking mode keywords
49
+ THINKING_MODES = ["think", "think hard", "think harder", "ultrathink"]
50
+
51
+
52
+ class CommandLocation(str, Enum):
53
+ """Where a custom command is sourced from."""
54
+
55
+ USER = "user"
56
+ PROJECT = "project"
57
+ OTHER = "other"
58
+
59
+
60
+ @dataclass
61
+ class CustomCommandDefinition:
62
+ """Parsed representation of a custom command."""
63
+
64
+ name: str
65
+ description: str
66
+ content: str
67
+ path: Path
68
+ base_dir: Path
69
+ location: CommandLocation
70
+ allowed_tools: List[str] = field(default_factory=list)
71
+ argument_hint: Optional[str] = None
72
+ model: Optional[str] = None
73
+ thinking_mode: Optional[str] = None
74
+
75
+
76
+ @dataclass
77
+ class CustomCommandLoadError:
78
+ """Error encountered while loading a custom command file."""
79
+
80
+ path: Path
81
+ reason: str
82
+
83
+
84
+ @dataclass
85
+ class CustomCommandLoadResult:
86
+ """Aggregated result of loading custom commands."""
87
+
88
+ commands: List[CustomCommandDefinition]
89
+ errors: List[CustomCommandLoadError]
90
+
91
+
92
+ def _split_frontmatter(raw_text: str) -> Tuple[Dict[str, Any], str]:
93
+ """Extract YAML frontmatter and body content from a markdown file."""
94
+ lines = raw_text.splitlines()
95
+ if len(lines) >= 3 and lines[0].strip() == "---":
96
+ for idx in range(1, len(lines)):
97
+ if lines[idx].strip() == "---":
98
+ frontmatter_text = "\n".join(lines[1:idx])
99
+ body = "\n".join(lines[idx + 1:])
100
+ try:
101
+ frontmatter = yaml.safe_load(frontmatter_text) or {}
102
+ except (yaml.YAMLError, ValueError, TypeError) as exc:
103
+ logger.warning(
104
+ "[custom_commands] Invalid frontmatter: %s: %s",
105
+ type(exc).__name__, exc,
106
+ )
107
+ return {"__error__": f"Invalid frontmatter: {exc}"}, body
108
+ return frontmatter, body
109
+ return {}, raw_text
110
+
111
+
112
+ def _normalize_allowed_tools(value: object) -> List[str]:
113
+ """Normalize allowed-tools values to a clean list of tool names."""
114
+ if value is None:
115
+ return []
116
+ if isinstance(value, str):
117
+ return [item.strip() for item in value.split(",") if item.strip()]
118
+ if isinstance(value, Iterable):
119
+ tools: List[str] = []
120
+ for item in value:
121
+ if isinstance(item, str) and item.strip():
122
+ tools.append(item.strip())
123
+ return tools
124
+ return []
125
+
126
+
127
+ def _extract_thinking_mode(content: str) -> Optional[str]:
128
+ """Extract thinking mode from content if present."""
129
+ content_lower = content.lower()
130
+ for mode in reversed(THINKING_MODES): # Check longer modes first
131
+ if mode in content_lower:
132
+ return mode
133
+ return None
134
+
135
+
136
+ def _derive_command_name(path: Path, base_commands_dir: Path) -> str:
137
+ """Derive command name from file path, supporting nested commands.
138
+
139
+ Examples:
140
+ commands/project-info.md -> project-info
141
+ commands/git/commit.md -> git:commit
142
+ commands/git/git-commit.md -> git:git-commit
143
+ """
144
+ relative = path.relative_to(base_commands_dir)
145
+ parts = list(relative.parts)
146
+
147
+ # Remove the .md suffix from the last part
148
+ if parts:
149
+ parts[-1] = parts[-1].removesuffix(COMMAND_FILE_SUFFIX)
150
+
151
+ # Join with : for nested commands
152
+ if len(parts) > 1:
153
+ return ":".join(parts)
154
+ return parts[0] if parts else path.stem
155
+
156
+
157
+ def _load_command_file(
158
+ path: Path, location: CommandLocation, base_commands_dir: Path
159
+ ) -> Tuple[Optional[CustomCommandDefinition], Optional[CustomCommandLoadError]]:
160
+ """Parse a single command .md file."""
161
+ try:
162
+ text = path.read_text(encoding="utf-8")
163
+ except (OSError, IOError, UnicodeDecodeError) as exc:
164
+ logger.warning(
165
+ "[custom_commands] Failed to read command file: %s: %s",
166
+ type(exc).__name__, exc,
167
+ extra={"path": str(path)},
168
+ )
169
+ return None, CustomCommandLoadError(path=path, reason=f"Failed to read file: {exc}")
170
+
171
+ frontmatter, body = _split_frontmatter(text)
172
+ if "__error__" in frontmatter:
173
+ return None, CustomCommandLoadError(path=path, reason=str(frontmatter["__error__"]))
174
+
175
+ # Derive command name from path (supports nested commands)
176
+ command_name = _derive_command_name(path, base_commands_dir)
177
+
178
+ # Get description from frontmatter or use first line of content
179
+ raw_description = frontmatter.get("description")
180
+ if not isinstance(raw_description, str) or not raw_description.strip():
181
+ # Use first line of content as description (truncated)
182
+ first_line = body.strip().split("\n")[0] if body.strip() else ""
183
+ raw_description = first_line[:60] + "..." if len(first_line) > 60 else first_line
184
+ if not raw_description:
185
+ raw_description = f"Custom command: {command_name}"
186
+
187
+ allowed_tools = _normalize_allowed_tools(
188
+ frontmatter.get("allowed-tools") or frontmatter.get("allowed_tools")
189
+ )
190
+
191
+ argument_hint = frontmatter.get("argument-hint") or frontmatter.get("argument_hint")
192
+ if argument_hint and not isinstance(argument_hint, str):
193
+ argument_hint = str(argument_hint)
194
+
195
+ model_value = frontmatter.get("model")
196
+ model = model_value.strip() if isinstance(model_value, str) and model_value.strip() else None
197
+
198
+ # Get thinking mode from frontmatter or content
199
+ thinking_mode = frontmatter.get("thinking-mode") or frontmatter.get("thinking_mode")
200
+ if not thinking_mode:
201
+ thinking_mode = _extract_thinking_mode(body)
202
+
203
+ command = CustomCommandDefinition(
204
+ name=command_name,
205
+ description=raw_description.strip(),
206
+ content=body.strip(),
207
+ path=path,
208
+ base_dir=path.parent,
209
+ location=location,
210
+ allowed_tools=allowed_tools,
211
+ argument_hint=argument_hint.strip() if argument_hint else None,
212
+ model=model,
213
+ thinking_mode=thinking_mode.strip() if isinstance(thinking_mode, str) else None,
214
+ )
215
+ return command, None
216
+
217
+
218
+ def _load_commands_from_dir(
219
+ commands_dir: Path, location: CommandLocation
220
+ ) -> Tuple[List[CustomCommandDefinition], List[CustomCommandLoadError]]:
221
+ """Load all custom commands from a directory, including nested subdirectories."""
222
+ commands: List[CustomCommandDefinition] = []
223
+ errors: List[CustomCommandLoadError] = []
224
+
225
+ if not commands_dir.exists() or not commands_dir.is_dir():
226
+ return commands, errors
227
+
228
+ # Recursively find all .md files
229
+ try:
230
+ for md_file in commands_dir.rglob(f"*{COMMAND_FILE_SUFFIX}"):
231
+ if not md_file.is_file():
232
+ continue
233
+
234
+ command, error = _load_command_file(md_file, location, commands_dir)
235
+ if command:
236
+ commands.append(command)
237
+ elif error:
238
+ errors.append(error)
239
+ except OSError as exc:
240
+ logger.warning(
241
+ "[custom_commands] Failed to scan command directory: %s: %s",
242
+ type(exc).__name__, exc,
243
+ extra={"path": str(commands_dir)},
244
+ )
245
+
246
+ return commands, errors
247
+
248
+
249
+ def command_directories(
250
+ project_path: Optional[Path] = None, home: Optional[Path] = None
251
+ ) -> List[Tuple[Path, CommandLocation]]:
252
+ """Return the standard command directories for user and project scopes."""
253
+ home_dir = (home or Path.home()).expanduser()
254
+ project_dir = (project_path or Path.cwd()).resolve()
255
+ return [
256
+ (home_dir / ".ripperdoc" / COMMAND_DIR_NAME, CommandLocation.USER),
257
+ (project_dir / ".ripperdoc" / COMMAND_DIR_NAME, CommandLocation.PROJECT),
258
+ ]
259
+
260
+
261
+ def load_all_custom_commands(
262
+ project_path: Optional[Path] = None, home: Optional[Path] = None
263
+ ) -> CustomCommandLoadResult:
264
+ """Load custom commands from user and project directories.
265
+
266
+ Project commands override user commands with the same name.
267
+ """
268
+ commands_by_name: Dict[str, CustomCommandDefinition] = {}
269
+ errors: List[CustomCommandLoadError] = []
270
+
271
+ # Load user commands first so project commands take precedence
272
+ for directory, location in command_directories(project_path=project_path, home=home):
273
+ loaded, dir_errors = _load_commands_from_dir(directory, location)
274
+ errors.extend(dir_errors)
275
+ for cmd in loaded:
276
+ if cmd.name in commands_by_name:
277
+ logger.debug(
278
+ "[custom_commands] Overriding command",
279
+ extra={
280
+ "command_name": cmd.name,
281
+ "previous_location": str(commands_by_name[cmd.name].location),
282
+ "new_location": str(location),
283
+ },
284
+ )
285
+ commands_by_name[cmd.name] = cmd
286
+
287
+ return CustomCommandLoadResult(commands=list(commands_by_name.values()), errors=errors)
288
+
289
+
290
+ def find_custom_command(
291
+ command_name: str, project_path: Optional[Path] = None, home: Optional[Path] = None
292
+ ) -> Optional[CustomCommandDefinition]:
293
+ """Find a custom command by name (case-sensitive match)."""
294
+ normalized = command_name.strip().lstrip("/")
295
+ if not normalized:
296
+ return None
297
+ result = load_all_custom_commands(project_path=project_path, home=home)
298
+ return next((cmd for cmd in result.commands if cmd.name == normalized), None)
299
+
300
+
301
+ def _execute_bash_command(command: str, cwd: Optional[Path] = None) -> str:
302
+ """Execute a bash command and return its output."""
303
+ try:
304
+ result = subprocess.run(
305
+ command,
306
+ shell=True,
307
+ capture_output=True,
308
+ text=True,
309
+ timeout=30,
310
+ cwd=cwd,
311
+ )
312
+ output = result.stdout.strip()
313
+ if result.returncode != 0 and result.stderr:
314
+ output = f"{output}\n{result.stderr}".strip()
315
+ return output if output else "(no output)"
316
+ except subprocess.TimeoutExpired:
317
+ return "(command timed out)"
318
+ except (OSError, subprocess.SubprocessError) as exc:
319
+ return f"(error: {exc})"
320
+
321
+
322
+ def _resolve_file_reference(filename: str, project_path: Path) -> str:
323
+ """Resolve a file reference and return its content."""
324
+ try:
325
+ file_path = project_path / filename
326
+ if file_path.exists() and file_path.is_file():
327
+ content = file_path.read_text(encoding="utf-8")
328
+ return content.strip()
329
+ return f"(file not found: {filename})"
330
+ except (OSError, IOError) as exc:
331
+ return f"(error reading {filename}: {exc})"
332
+
333
+
334
+ def expand_command_content(
335
+ command: CustomCommandDefinition,
336
+ arguments: str,
337
+ project_path: Optional[Path] = None,
338
+ ) -> str:
339
+ """Expand a custom command's content with arguments, bash commands, and file references.
340
+
341
+ Supports:
342
+ - $ARGUMENTS: All arguments as a single string
343
+ - $1, $2, etc.: Positional arguments
344
+ - !`command`: Execute bash command and include output
345
+ - @filename: Include file content
346
+ """
347
+ project_dir = (project_path or Path.cwd()).resolve()
348
+ content = command.content
349
+
350
+ # Split arguments for positional access
351
+ arg_parts = arguments.split() if arguments else []
352
+
353
+ # Replace $ARGUMENTS with all arguments
354
+ content = _ALL_ARGS_PATTERN.sub(arguments, content)
355
+
356
+ # Replace positional arguments $1, $2, etc.
357
+ def replace_positional(match: re.Match) -> str:
358
+ idx = int(match.group(1)) - 1 # $1 -> index 0
359
+ if 0 <= idx < len(arg_parts):
360
+ return arg_parts[idx]
361
+ return ""
362
+
363
+ content = _POSITIONAL_ARG_PATTERN.sub(replace_positional, content)
364
+
365
+ # Execute bash commands: !`command`
366
+ def replace_bash(match: re.Match) -> str:
367
+ bash_cmd = match.group(1)
368
+ return _execute_bash_command(bash_cmd, cwd=project_dir)
369
+
370
+ content = _BASH_COMMAND_PATTERN.sub(replace_bash, content)
371
+
372
+ # Resolve file references: @filename
373
+ def replace_file_ref(match: re.Match) -> str:
374
+ filename = match.group(1)
375
+ return _resolve_file_reference(filename, project_dir)
376
+
377
+ content = _FILE_REFERENCE_PATTERN.sub(replace_file_ref, content)
378
+
379
+ return content.strip()
380
+
381
+
382
+ def build_custom_command_summary(commands: Sequence[CustomCommandDefinition]) -> str:
383
+ """Render a concise instruction block listing available custom commands."""
384
+ if not commands:
385
+ return ""
386
+
387
+ lines = [
388
+ "# Custom Commands",
389
+ "The following custom commands are available:",
390
+ ]
391
+
392
+ for cmd in sorted(commands, key=lambda c: c.name):
393
+ location = f" ({cmd.location.value})" if cmd.location else ""
394
+ hint = f" {cmd.argument_hint}" if cmd.argument_hint else ""
395
+ lines.append(f"- /{cmd.name}{hint}{location}: {cmd.description}")
396
+
397
+ return "\n".join(lines)
398
+
399
+
400
+ __all__ = [
401
+ "CustomCommandDefinition",
402
+ "CustomCommandLoadError",
403
+ "CustomCommandLoadResult",
404
+ "CommandLocation",
405
+ "COMMAND_DIR_NAME",
406
+ "load_all_custom_commands",
407
+ "find_custom_command",
408
+ "expand_command_content",
409
+ "build_custom_command_summary",
410
+ "command_directories",
411
+ ]
@@ -0,0 +1,99 @@
1
+ """Hooks system for Ripperdoc.
2
+
3
+ This module provides a hook system similar to Claude Code CLI's hooks,
4
+ allowing users to execute custom scripts at various points in the workflow.
5
+
6
+ Hook events:
7
+ - PreToolUse: Before a tool is called (can block/allow/ask)
8
+ - PermissionRequest: When user is shown permission dialog (can allow/deny)
9
+ - PostToolUse: After a tool completes
10
+ - UserPromptSubmit: When user submits a prompt (can block)
11
+ - Notification: When a notification is sent
12
+ - Stop: When the agent stops responding (can block to continue)
13
+ - SubagentStop: When a subagent task completes (can block to continue)
14
+ - PreCompact: Before conversation compaction
15
+ - SessionStart: When a session starts or resumes
16
+ - SessionEnd: When a session ends
17
+
18
+ Configuration is stored in:
19
+ - ~/.ripperdoc/hooks.json (global)
20
+ - .ripperdoc/hooks.json (project)
21
+ - .ripperdoc/hooks.local.json (local, git-ignored)
22
+ """
23
+
24
+ from ripperdoc.core.hooks.events import (
25
+ HookEvent,
26
+ HookDecision,
27
+ HookInput,
28
+ HookOutput,
29
+ PreToolUseInput,
30
+ PermissionRequestInput,
31
+ PostToolUseInput,
32
+ UserPromptSubmitInput,
33
+ NotificationInput,
34
+ StopInput,
35
+ SubagentStopInput,
36
+ PreCompactInput,
37
+ SessionStartInput,
38
+ SessionEndInput,
39
+ PreToolUseHookOutput,
40
+ PermissionRequestHookOutput,
41
+ PermissionRequestDecision,
42
+ PostToolUseHookOutput,
43
+ UserPromptSubmitHookOutput,
44
+ SessionStartHookOutput,
45
+ )
46
+ from ripperdoc.core.hooks.config import (
47
+ HookDefinition,
48
+ HookMatcher,
49
+ HooksConfig,
50
+ load_hooks_config,
51
+ get_merged_hooks_config,
52
+ get_global_hooks_path,
53
+ get_project_hooks_path,
54
+ get_project_local_hooks_path,
55
+ )
56
+ from ripperdoc.core.hooks.executor import HookExecutor, LLMCallback
57
+ from ripperdoc.core.hooks.manager import HookManager, HookResult, hook_manager, init_hook_manager
58
+
59
+ __all__ = [
60
+ # Events
61
+ "HookEvent",
62
+ "HookDecision",
63
+ "HookInput",
64
+ "HookOutput",
65
+ "PreToolUseInput",
66
+ "PermissionRequestInput",
67
+ "PostToolUseInput",
68
+ "UserPromptSubmitInput",
69
+ "NotificationInput",
70
+ "StopInput",
71
+ "SubagentStopInput",
72
+ "PreCompactInput",
73
+ "SessionStartInput",
74
+ "SessionEndInput",
75
+ # Hook-specific outputs
76
+ "PreToolUseHookOutput",
77
+ "PermissionRequestHookOutput",
78
+ "PermissionRequestDecision",
79
+ "PostToolUseHookOutput",
80
+ "UserPromptSubmitHookOutput",
81
+ "SessionStartHookOutput",
82
+ # Config
83
+ "HookDefinition",
84
+ "HookMatcher",
85
+ "HooksConfig",
86
+ "load_hooks_config",
87
+ "get_merged_hooks_config",
88
+ "get_global_hooks_path",
89
+ "get_project_hooks_path",
90
+ "get_project_local_hooks_path",
91
+ # Executor
92
+ "HookExecutor",
93
+ "LLMCallback",
94
+ # Manager
95
+ "HookManager",
96
+ "HookResult",
97
+ "hook_manager",
98
+ "init_hook_manager",
99
+ ]