ripperdoc 0.2.6__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.
- ripperdoc/__init__.py +1 -1
- ripperdoc/cli/cli.py +5 -0
- ripperdoc/cli/commands/__init__.py +71 -6
- ripperdoc/cli/commands/clear_cmd.py +1 -0
- ripperdoc/cli/commands/exit_cmd.py +1 -1
- ripperdoc/cli/commands/help_cmd.py +11 -1
- ripperdoc/cli/commands/hooks_cmd.py +636 -0
- ripperdoc/cli/commands/permissions_cmd.py +36 -34
- ripperdoc/cli/commands/resume_cmd.py +71 -37
- ripperdoc/cli/ui/file_mention_completer.py +276 -0
- ripperdoc/cli/ui/helpers.py +100 -3
- ripperdoc/cli/ui/interrupt_handler.py +175 -0
- ripperdoc/cli/ui/message_display.py +249 -0
- ripperdoc/cli/ui/panels.py +63 -0
- ripperdoc/cli/ui/rich_ui.py +233 -648
- ripperdoc/cli/ui/tool_renderers.py +2 -2
- ripperdoc/core/agents.py +4 -4
- ripperdoc/core/custom_commands.py +411 -0
- ripperdoc/core/hooks/__init__.py +99 -0
- ripperdoc/core/hooks/config.py +303 -0
- ripperdoc/core/hooks/events.py +540 -0
- ripperdoc/core/hooks/executor.py +498 -0
- ripperdoc/core/hooks/integration.py +353 -0
- ripperdoc/core/hooks/manager.py +720 -0
- ripperdoc/core/providers/anthropic.py +476 -69
- ripperdoc/core/query.py +61 -4
- ripperdoc/core/query_utils.py +1 -1
- ripperdoc/core/tool.py +1 -1
- ripperdoc/tools/bash_tool.py +5 -5
- ripperdoc/tools/file_edit_tool.py +2 -2
- ripperdoc/tools/file_read_tool.py +2 -2
- ripperdoc/tools/multi_edit_tool.py +1 -1
- ripperdoc/utils/conversation_compaction.py +476 -0
- ripperdoc/utils/message_compaction.py +109 -154
- ripperdoc/utils/message_formatting.py +216 -0
- ripperdoc/utils/messages.py +31 -9
- ripperdoc/utils/path_ignore.py +3 -4
- ripperdoc/utils/session_history.py +19 -7
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/METADATA +24 -3
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/RECORD +44 -30
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/WHEEL +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/entry_points.txt +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/licenses/LICENSE +0 -0
- {ripperdoc-0.2.6.dist-info → ripperdoc-0.2.8.dist-info}/top_level.txt +0 -0
|
@@ -0,0 +1,636 @@
|
|
|
1
|
+
"""Hooks command - view and edit hook configurations with guided prompts."""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
from dataclasses import dataclass
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
from typing import Any, Dict, List, Optional, Sequence
|
|
9
|
+
|
|
10
|
+
from rich.markup import escape
|
|
11
|
+
from rich.table import Table
|
|
12
|
+
|
|
13
|
+
from .base import SlashCommand
|
|
14
|
+
from ripperdoc.core.hooks import (
|
|
15
|
+
HookEvent,
|
|
16
|
+
get_merged_hooks_config,
|
|
17
|
+
get_global_hooks_path,
|
|
18
|
+
get_project_hooks_path,
|
|
19
|
+
get_project_local_hooks_path,
|
|
20
|
+
)
|
|
21
|
+
from ripperdoc.core.hooks.config import DEFAULT_HOOK_TIMEOUT, PROMPT_SUPPORTED_EVENTS
|
|
22
|
+
from ripperdoc.utils.log import get_logger
|
|
23
|
+
|
|
24
|
+
logger = get_logger()
|
|
25
|
+
|
|
26
|
+
MATCHER_EVENTS = {
|
|
27
|
+
HookEvent.PRE_TOOL_USE.value,
|
|
28
|
+
HookEvent.PERMISSION_REQUEST.value,
|
|
29
|
+
HookEvent.POST_TOOL_USE.value,
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
EVENT_DESCRIPTIONS: Dict[str, str] = {
|
|
33
|
+
HookEvent.PRE_TOOL_USE.value: "Before a tool runs (can block or edit input)",
|
|
34
|
+
HookEvent.PERMISSION_REQUEST.value: "When a permission dialog is shown",
|
|
35
|
+
HookEvent.POST_TOOL_USE.value: "After a tool finishes running",
|
|
36
|
+
HookEvent.USER_PROMPT_SUBMIT.value: "When you submit a prompt",
|
|
37
|
+
HookEvent.NOTIFICATION.value: "When Ripperdoc sends a notification",
|
|
38
|
+
HookEvent.STOP.value: "When the agent stops responding",
|
|
39
|
+
HookEvent.SUBAGENT_STOP.value: "When a Task/subagent stops",
|
|
40
|
+
HookEvent.PRE_COMPACT.value: "Before compacting the conversation",
|
|
41
|
+
HookEvent.SESSION_START.value: "When a session starts or resumes",
|
|
42
|
+
HookEvent.SESSION_END.value: "When a session ends",
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
@dataclass(frozen=True)
|
|
47
|
+
class HookConfigTarget:
|
|
48
|
+
"""Target file for storing hooks."""
|
|
49
|
+
|
|
50
|
+
key: str
|
|
51
|
+
label: str
|
|
52
|
+
path: Path
|
|
53
|
+
description: str
|
|
54
|
+
|
|
55
|
+
|
|
56
|
+
def _print_usage(console: Any) -> None:
|
|
57
|
+
"""Display available subcommands."""
|
|
58
|
+
console.print("[bold]/hooks[/bold] — show configured hooks")
|
|
59
|
+
console.print(
|
|
60
|
+
"[bold]/hooks add [scope][/bold] — guided creator (scope: local|project|global)"
|
|
61
|
+
)
|
|
62
|
+
console.print(
|
|
63
|
+
"[bold]/hooks edit [scope][/bold] — step-by-step edit of an existing hook"
|
|
64
|
+
)
|
|
65
|
+
console.print(
|
|
66
|
+
"[bold]/hooks delete [scope][/bold] — remove a hook entry (alias: del)"
|
|
67
|
+
)
|
|
68
|
+
console.print(
|
|
69
|
+
"[dim]Scopes: local=.ripperdoc/hooks.local.json (git-ignored), "
|
|
70
|
+
"project=.ripperdoc/hooks.json (shared), "
|
|
71
|
+
"global=~/.ripperdoc/hooks.json (all projects)[/dim]"
|
|
72
|
+
)
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
def _get_targets(project_path: Path) -> List[HookConfigTarget]:
|
|
76
|
+
"""Return available hook config destinations."""
|
|
77
|
+
return [
|
|
78
|
+
HookConfigTarget(
|
|
79
|
+
key="local",
|
|
80
|
+
label="Local (.ripperdoc/hooks.local.json)",
|
|
81
|
+
path=get_project_local_hooks_path(project_path),
|
|
82
|
+
description="Git-ignored hooks for this project",
|
|
83
|
+
),
|
|
84
|
+
HookConfigTarget(
|
|
85
|
+
key="project",
|
|
86
|
+
label="Project (.ripperdoc/hooks.json)",
|
|
87
|
+
path=get_project_hooks_path(project_path),
|
|
88
|
+
description="Shared hooks committed to the repo",
|
|
89
|
+
),
|
|
90
|
+
HookConfigTarget(
|
|
91
|
+
key="global",
|
|
92
|
+
label="Global (~/.ripperdoc/hooks.json)",
|
|
93
|
+
path=get_global_hooks_path(),
|
|
94
|
+
description="Applies to all projects on this machine",
|
|
95
|
+
),
|
|
96
|
+
]
|
|
97
|
+
|
|
98
|
+
|
|
99
|
+
def _select_target(console: Any, project_path: Path, scope_hint: Optional[str]) -> Optional[HookConfigTarget]:
|
|
100
|
+
"""Prompt user to choose a hooks config target."""
|
|
101
|
+
targets = _get_targets(project_path)
|
|
102
|
+
|
|
103
|
+
if scope_hint:
|
|
104
|
+
match = next(
|
|
105
|
+
(t for t in targets if t.key.startswith(scope_hint.lower())),
|
|
106
|
+
None,
|
|
107
|
+
)
|
|
108
|
+
if match:
|
|
109
|
+
return match
|
|
110
|
+
console.print(
|
|
111
|
+
f"[yellow]Unknown scope '{escape(scope_hint)}'. Choose from the options below.[/yellow]"
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
default_idx = 0
|
|
115
|
+
console.print("\n[bold]Where should this hook live?[/bold]")
|
|
116
|
+
while True:
|
|
117
|
+
for idx, target in enumerate(targets, start=1):
|
|
118
|
+
status = "[green]✓[/green]" if target.path.exists() else "[dim]○[/dim]"
|
|
119
|
+
console.print(
|
|
120
|
+
f" [{idx}] {target.label} {status}\n"
|
|
121
|
+
f" {escape(str(target.path))}\n"
|
|
122
|
+
f" [dim]{target.description}[/dim]"
|
|
123
|
+
)
|
|
124
|
+
|
|
125
|
+
choice = console.input(
|
|
126
|
+
f"Location [1-{len(targets)}, default {default_idx + 1}]: "
|
|
127
|
+
).strip()
|
|
128
|
+
|
|
129
|
+
if not choice:
|
|
130
|
+
return targets[default_idx]
|
|
131
|
+
|
|
132
|
+
for idx, target in enumerate(targets, start=1):
|
|
133
|
+
if choice == str(idx) or choice.lower() == target.key:
|
|
134
|
+
return target
|
|
135
|
+
|
|
136
|
+
console.print("[red]Please choose a valid location number or key.[/red]")
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _load_hooks_json(console: Any, path: Path) -> Dict[str, List[Dict[str, Any]]]:
|
|
140
|
+
"""Load hooks config from disk, normalizing the structure."""
|
|
141
|
+
if not path.exists():
|
|
142
|
+
return {}
|
|
143
|
+
|
|
144
|
+
try:
|
|
145
|
+
raw = json.loads(path.read_text(encoding="utf-8"))
|
|
146
|
+
except json.JSONDecodeError as exc:
|
|
147
|
+
console.print(
|
|
148
|
+
f"[red]Invalid JSON in {escape(str(path))}: {escape(str(exc))}. "
|
|
149
|
+
"Starting from an empty config.[/red]"
|
|
150
|
+
)
|
|
151
|
+
logger.warning("[hooks_cmd] Invalid JSON in %s: %s", path, exc)
|
|
152
|
+
return {}
|
|
153
|
+
except (OSError, IOError, PermissionError) as exc:
|
|
154
|
+
console.print(
|
|
155
|
+
f"[red]Unable to read {escape(str(path))}: {escape(str(exc))}[/red]"
|
|
156
|
+
)
|
|
157
|
+
logger.warning("[hooks_cmd] Failed to read %s: %s", path, exc)
|
|
158
|
+
return {}
|
|
159
|
+
|
|
160
|
+
hooks_section = {}
|
|
161
|
+
if isinstance(raw, dict):
|
|
162
|
+
hooks_section = raw.get("hooks", raw)
|
|
163
|
+
|
|
164
|
+
if not isinstance(hooks_section, dict):
|
|
165
|
+
return {}
|
|
166
|
+
|
|
167
|
+
hooks: Dict[str, List[Dict[str, Any]]] = {}
|
|
168
|
+
for event_name, matchers in hooks_section.items():
|
|
169
|
+
if not isinstance(matchers, list):
|
|
170
|
+
continue
|
|
171
|
+
cleaned_matchers: List[Dict[str, Any]] = []
|
|
172
|
+
for matcher in matchers:
|
|
173
|
+
if not isinstance(matcher, dict):
|
|
174
|
+
continue
|
|
175
|
+
hooks_list = matcher.get("hooks", [])
|
|
176
|
+
if not isinstance(hooks_list, list):
|
|
177
|
+
continue
|
|
178
|
+
cleaned_hooks = [h for h in hooks_list if isinstance(h, dict)]
|
|
179
|
+
cleaned_matchers.append(
|
|
180
|
+
{"matcher": matcher.get("matcher"), "hooks": cleaned_hooks}
|
|
181
|
+
)
|
|
182
|
+
if cleaned_matchers:
|
|
183
|
+
hooks[event_name] = cleaned_matchers
|
|
184
|
+
|
|
185
|
+
return hooks
|
|
186
|
+
|
|
187
|
+
|
|
188
|
+
def _save_hooks_json(console: Any, path: Path, hooks: Dict[str, List[Dict[str, Any]]]) -> bool:
|
|
189
|
+
"""Persist hooks to disk with indentation."""
|
|
190
|
+
try:
|
|
191
|
+
path.parent.mkdir(parents=True, exist_ok=True)
|
|
192
|
+
serialized = json.dumps({"hooks": hooks}, indent=2, ensure_ascii=False)
|
|
193
|
+
path.write_text(serialized, encoding="utf-8")
|
|
194
|
+
return True
|
|
195
|
+
except (OSError, IOError, PermissionError) as exc:
|
|
196
|
+
console.print(
|
|
197
|
+
f"[red]Failed to write hooks to {escape(str(path))}: {escape(str(exc))}[/red]"
|
|
198
|
+
)
|
|
199
|
+
logger.warning("[hooks_cmd] Failed to write %s: %s", path, exc)
|
|
200
|
+
return False
|
|
201
|
+
|
|
202
|
+
|
|
203
|
+
def _summarize_hook(hook: Dict[str, Any]) -> str:
|
|
204
|
+
"""Return a short human-friendly summary of a hook."""
|
|
205
|
+
hook_type = hook.get("type", "command")
|
|
206
|
+
if hook_type == "prompt":
|
|
207
|
+
text = hook.get("prompt", "") or "(prompt missing)"
|
|
208
|
+
label = "prompt"
|
|
209
|
+
else:
|
|
210
|
+
text = hook.get("command", "") or "(command missing)"
|
|
211
|
+
label = "command"
|
|
212
|
+
|
|
213
|
+
text = text.replace("\n", "\\n")
|
|
214
|
+
if len(text) > 60:
|
|
215
|
+
text = text[:57] + "..."
|
|
216
|
+
|
|
217
|
+
timeout = hook.get("timeout", DEFAULT_HOOK_TIMEOUT)
|
|
218
|
+
return f"{label}: {text} ({timeout}s)"
|
|
219
|
+
|
|
220
|
+
|
|
221
|
+
def _render_hooks_overview(ui: Any, project_path: Path) -> bool:
|
|
222
|
+
"""Display merged hooks with file locations."""
|
|
223
|
+
config = get_merged_hooks_config(project_path)
|
|
224
|
+
targets = _get_targets(project_path)
|
|
225
|
+
|
|
226
|
+
ui.console.print()
|
|
227
|
+
ui.console.print("[bold]Hook Configuration Files[/bold]")
|
|
228
|
+
for target in targets:
|
|
229
|
+
if target.path.exists():
|
|
230
|
+
ui.console.print(f" [green]✓[/green] {target.label}: {target.path}")
|
|
231
|
+
else:
|
|
232
|
+
ui.console.print(
|
|
233
|
+
f" [dim]○[/dim] {target.label}: {target.path} [dim](not found)[/dim]"
|
|
234
|
+
)
|
|
235
|
+
|
|
236
|
+
ui.console.print()
|
|
237
|
+
|
|
238
|
+
total_hooks = 0
|
|
239
|
+
for matchers in config.hooks.values():
|
|
240
|
+
for matcher in matchers:
|
|
241
|
+
total_hooks += len(matcher.hooks)
|
|
242
|
+
|
|
243
|
+
if not config.hooks:
|
|
244
|
+
ui.console.print(
|
|
245
|
+
"[yellow]No hooks configured.[/yellow]\n"
|
|
246
|
+
"Use /hooks add to create one with a guided editor."
|
|
247
|
+
)
|
|
248
|
+
return True
|
|
249
|
+
|
|
250
|
+
ui.console.print(f"[bold]Registered Hooks[/bold] ({total_hooks} total)\n")
|
|
251
|
+
|
|
252
|
+
for event in HookEvent:
|
|
253
|
+
event_name = event.value
|
|
254
|
+
if event_name not in config.hooks:
|
|
255
|
+
continue
|
|
256
|
+
|
|
257
|
+
matchers = config.hooks[event_name]
|
|
258
|
+
if not matchers:
|
|
259
|
+
continue
|
|
260
|
+
|
|
261
|
+
table = Table(
|
|
262
|
+
title=f"[bold cyan]{event_name}[/bold cyan]",
|
|
263
|
+
show_header=True,
|
|
264
|
+
header_style="bold",
|
|
265
|
+
expand=True,
|
|
266
|
+
title_justify="left",
|
|
267
|
+
)
|
|
268
|
+
table.add_column("Matcher", style="yellow", width=20)
|
|
269
|
+
table.add_column("Command", style="green")
|
|
270
|
+
table.add_column("Timeout", style="dim", width=8, justify="right")
|
|
271
|
+
|
|
272
|
+
for matcher in matchers:
|
|
273
|
+
matcher_str = matcher.matcher or "*"
|
|
274
|
+
for i, hook in enumerate(matcher.hooks):
|
|
275
|
+
cmd_text = hook.command or hook.prompt or ""
|
|
276
|
+
prefix = "[prompt] " if hook.prompt and not hook.command else ""
|
|
277
|
+
if len(cmd_text) > 60:
|
|
278
|
+
cmd_text = cmd_text[:57] + "..."
|
|
279
|
+
|
|
280
|
+
if i == 0:
|
|
281
|
+
table.add_row(
|
|
282
|
+
escape(matcher_str),
|
|
283
|
+
escape(prefix + cmd_text),
|
|
284
|
+
f"{hook.timeout}s",
|
|
285
|
+
)
|
|
286
|
+
else:
|
|
287
|
+
table.add_row("", escape(prefix + cmd_text), f"{hook.timeout}s")
|
|
288
|
+
|
|
289
|
+
ui.console.print(table)
|
|
290
|
+
ui.console.print()
|
|
291
|
+
|
|
292
|
+
ui.console.print(
|
|
293
|
+
"[dim]Tip: Hooks run in order. /hooks add launches a guided setup.[/dim]"
|
|
294
|
+
)
|
|
295
|
+
return True
|
|
296
|
+
|
|
297
|
+
|
|
298
|
+
def _prompt_event_selection(
|
|
299
|
+
console: Any,
|
|
300
|
+
available_events: Sequence[str],
|
|
301
|
+
default_event: Optional[str] = None,
|
|
302
|
+
) -> Optional[str]:
|
|
303
|
+
"""Prompt user to choose a hook event."""
|
|
304
|
+
if not available_events:
|
|
305
|
+
console.print("[red]No hook events available to choose from.[/red]")
|
|
306
|
+
return None
|
|
307
|
+
|
|
308
|
+
default_idx = 0
|
|
309
|
+
if default_event and default_event in available_events:
|
|
310
|
+
default_idx = available_events.index(default_event)
|
|
311
|
+
|
|
312
|
+
console.print("\n[bold]Select hook event:[/bold]")
|
|
313
|
+
for idx, event_name in enumerate(available_events, start=1):
|
|
314
|
+
desc = EVENT_DESCRIPTIONS.get(event_name, "Custom event")
|
|
315
|
+
console.print(f" [{idx}] {event_name} — {desc}")
|
|
316
|
+
|
|
317
|
+
while True:
|
|
318
|
+
choice = console.input(
|
|
319
|
+
f"Event [1-{len(available_events)}, default {default_idx + 1}]: "
|
|
320
|
+
).strip()
|
|
321
|
+
if not choice:
|
|
322
|
+
return available_events[default_idx]
|
|
323
|
+
|
|
324
|
+
if choice.isdigit():
|
|
325
|
+
idx = int(choice) - 1
|
|
326
|
+
if 0 <= idx < len(available_events):
|
|
327
|
+
return available_events[idx]
|
|
328
|
+
|
|
329
|
+
console.print("[red]Please enter a number from the list.[/red]")
|
|
330
|
+
|
|
331
|
+
|
|
332
|
+
def _prompt_matcher_selection(
|
|
333
|
+
console: Any,
|
|
334
|
+
event_name: str,
|
|
335
|
+
matchers: List[Dict[str, Any]],
|
|
336
|
+
) -> Optional[Dict[str, Any]]:
|
|
337
|
+
"""Prompt for matcher selection or creation."""
|
|
338
|
+
if event_name not in MATCHER_EVENTS:
|
|
339
|
+
if matchers:
|
|
340
|
+
return matchers[0]
|
|
341
|
+
matcher = {"matcher": None, "hooks": []}
|
|
342
|
+
matchers.append(matcher)
|
|
343
|
+
return matcher
|
|
344
|
+
|
|
345
|
+
if not matchers:
|
|
346
|
+
console.print(
|
|
347
|
+
"\nMatcher (tool name or regex). Leave empty to match all tools (*)."
|
|
348
|
+
)
|
|
349
|
+
pattern = console.input("Matcher: ").strip() or "*"
|
|
350
|
+
matcher = {"matcher": pattern, "hooks": []}
|
|
351
|
+
matchers.append(matcher)
|
|
352
|
+
return matcher
|
|
353
|
+
|
|
354
|
+
console.print("\nSelect matcher:")
|
|
355
|
+
for idx, matcher in enumerate(matchers, start=1):
|
|
356
|
+
label = matcher.get("matcher") or "*"
|
|
357
|
+
hook_count = len(matcher.get("hooks", []))
|
|
358
|
+
console.print(f" [{idx}] {escape(str(label))} ({hook_count} hook(s))")
|
|
359
|
+
new_idx = len(matchers) + 1
|
|
360
|
+
console.print(f" [{new_idx}] New matcher pattern")
|
|
361
|
+
|
|
362
|
+
default_choice = 1
|
|
363
|
+
while True:
|
|
364
|
+
choice = console.input(
|
|
365
|
+
f"Matcher [1-{new_idx}, default {default_choice}]: "
|
|
366
|
+
).strip()
|
|
367
|
+
if not choice:
|
|
368
|
+
choice = str(default_choice)
|
|
369
|
+
if choice.isdigit():
|
|
370
|
+
idx = int(choice)
|
|
371
|
+
if 1 <= idx <= len(matchers):
|
|
372
|
+
return matchers[idx - 1]
|
|
373
|
+
if idx == new_idx:
|
|
374
|
+
pattern = console.input("New matcher (blank for '*'): ").strip() or "*"
|
|
375
|
+
matcher = {"matcher": pattern, "hooks": []}
|
|
376
|
+
matchers.append(matcher)
|
|
377
|
+
return matcher
|
|
378
|
+
console.print("[red]Choose a matcher number from the list.[/red]")
|
|
379
|
+
|
|
380
|
+
|
|
381
|
+
def _prompt_timeout(console: Any, default_timeout: int) -> int:
|
|
382
|
+
"""Prompt for timeout seconds with validation."""
|
|
383
|
+
while True:
|
|
384
|
+
raw = console.input(f"Timeout seconds [{default_timeout}]: ").strip()
|
|
385
|
+
if not raw:
|
|
386
|
+
return default_timeout
|
|
387
|
+
if raw.isdigit() and int(raw) > 0:
|
|
388
|
+
return int(raw)
|
|
389
|
+
console.print("[red]Please enter a positive integer timeout.[/red]")
|
|
390
|
+
|
|
391
|
+
|
|
392
|
+
def _prompt_hook_details(
|
|
393
|
+
console: Any,
|
|
394
|
+
event_name: str,
|
|
395
|
+
existing_hook: Optional[Dict[str, Any]] = None,
|
|
396
|
+
) -> Optional[Dict[str, Any]]:
|
|
397
|
+
"""Collect hook details (type, command/prompt, timeout)."""
|
|
398
|
+
allowed_types = ["command"]
|
|
399
|
+
if event_name in PROMPT_SUPPORTED_EVENTS:
|
|
400
|
+
allowed_types.append("prompt")
|
|
401
|
+
|
|
402
|
+
default_type = (existing_hook or {}).get("type", allowed_types[0])
|
|
403
|
+
if default_type not in allowed_types:
|
|
404
|
+
default_type = allowed_types[0]
|
|
405
|
+
|
|
406
|
+
type_label = "/".join(allowed_types)
|
|
407
|
+
console.print(
|
|
408
|
+
"[dim]Hook type: 'command' runs a shell command; "
|
|
409
|
+
"'prompt' asks the model to evaluate (supported on selected events).[/dim]"
|
|
410
|
+
)
|
|
411
|
+
while True:
|
|
412
|
+
hook_type = (
|
|
413
|
+
console.input(
|
|
414
|
+
f"Hook type ({type_label}) [default {default_type}]: "
|
|
415
|
+
).strip()
|
|
416
|
+
or default_type
|
|
417
|
+
).lower()
|
|
418
|
+
if hook_type in allowed_types:
|
|
419
|
+
break
|
|
420
|
+
console.print("[red]Please choose a supported hook type.[/red]")
|
|
421
|
+
|
|
422
|
+
timeout_default = (existing_hook or {}).get("timeout", DEFAULT_HOOK_TIMEOUT)
|
|
423
|
+
|
|
424
|
+
if hook_type == "prompt":
|
|
425
|
+
existing_prompt = (existing_hook or {}).get("prompt", "")
|
|
426
|
+
if existing_prompt:
|
|
427
|
+
console.print(
|
|
428
|
+
f"[dim]Current prompt:[/dim] {escape(existing_prompt)}", markup=False
|
|
429
|
+
)
|
|
430
|
+
while True:
|
|
431
|
+
prompt_text = (
|
|
432
|
+
console.input(
|
|
433
|
+
"Prompt template (use $ARGUMENTS for JSON input): "
|
|
434
|
+
).strip()
|
|
435
|
+
or existing_prompt
|
|
436
|
+
)
|
|
437
|
+
if prompt_text:
|
|
438
|
+
break
|
|
439
|
+
console.print("[red]Prompt text is required for prompt hooks.[/red]")
|
|
440
|
+
timeout = _prompt_timeout(console, timeout_default)
|
|
441
|
+
return {"type": "prompt", "prompt": prompt_text, "timeout": timeout}
|
|
442
|
+
|
|
443
|
+
# Command hook
|
|
444
|
+
existing_command = (existing_hook or {}).get("command", "")
|
|
445
|
+
while True:
|
|
446
|
+
command = (
|
|
447
|
+
console.input(
|
|
448
|
+
f"Command to run{f' [{existing_command}]' if existing_command else ''}: "
|
|
449
|
+
).strip()
|
|
450
|
+
or existing_command
|
|
451
|
+
)
|
|
452
|
+
if command:
|
|
453
|
+
break
|
|
454
|
+
console.print("[red]Command is required for command hooks.[/red]")
|
|
455
|
+
|
|
456
|
+
timeout = _prompt_timeout(console, timeout_default)
|
|
457
|
+
return {"type": "command", "command": command, "timeout": timeout}
|
|
458
|
+
|
|
459
|
+
|
|
460
|
+
def _select_hook(console: Any, matcher: Dict[str, Any]) -> Optional[int]:
|
|
461
|
+
"""Prompt the user to choose a specific hook index."""
|
|
462
|
+
hooks = matcher.get("hooks", [])
|
|
463
|
+
if not hooks:
|
|
464
|
+
console.print("[yellow]No hooks found under this matcher.[/yellow]")
|
|
465
|
+
return None
|
|
466
|
+
|
|
467
|
+
console.print("\nSelect hook:")
|
|
468
|
+
for idx, hook in enumerate(hooks, start=1):
|
|
469
|
+
console.print(f" [{idx}] {_summarize_hook(hook)}")
|
|
470
|
+
|
|
471
|
+
while True:
|
|
472
|
+
choice = console.input(f"Hook [1-{len(hooks)}]: ").strip()
|
|
473
|
+
if choice.isdigit():
|
|
474
|
+
idx = int(choice) - 1
|
|
475
|
+
if 0 <= idx < len(hooks):
|
|
476
|
+
return idx
|
|
477
|
+
console.print("[red]Choose a hook number from the list.[/red]")
|
|
478
|
+
|
|
479
|
+
|
|
480
|
+
def _handle_add(ui: Any, tokens: List[str], project_path: Path) -> bool:
|
|
481
|
+
"""Handle /hooks add."""
|
|
482
|
+
console = ui.console
|
|
483
|
+
target = _select_target(console, project_path, tokens[0] if tokens else None)
|
|
484
|
+
if not target:
|
|
485
|
+
return True
|
|
486
|
+
|
|
487
|
+
hooks = _load_hooks_json(console, target.path)
|
|
488
|
+
event_name = _prompt_event_selection(
|
|
489
|
+
console, [e.value for e in HookEvent], HookEvent.PRE_TOOL_USE.value
|
|
490
|
+
)
|
|
491
|
+
if not event_name:
|
|
492
|
+
return True
|
|
493
|
+
|
|
494
|
+
matchers = hooks.setdefault(event_name, [])
|
|
495
|
+
matcher = _prompt_matcher_selection(console, event_name, matchers)
|
|
496
|
+
if matcher is None:
|
|
497
|
+
return True
|
|
498
|
+
|
|
499
|
+
hook_def = _prompt_hook_details(console, event_name)
|
|
500
|
+
if not hook_def:
|
|
501
|
+
return True
|
|
502
|
+
|
|
503
|
+
matcher.setdefault("hooks", []).append(hook_def)
|
|
504
|
+
if not _save_hooks_json(console, target.path, hooks):
|
|
505
|
+
return True
|
|
506
|
+
|
|
507
|
+
console.print(
|
|
508
|
+
f"[green]✓ Added hook to {escape(str(target.path))} under {escape(event_name)}.[/green]"
|
|
509
|
+
)
|
|
510
|
+
return True
|
|
511
|
+
|
|
512
|
+
|
|
513
|
+
def _handle_edit(ui: Any, tokens: List[str], project_path: Path) -> bool:
|
|
514
|
+
"""Handle /hooks edit."""
|
|
515
|
+
console = ui.console
|
|
516
|
+
target = _select_target(console, project_path, tokens[0] if tokens else None)
|
|
517
|
+
if not target:
|
|
518
|
+
return True
|
|
519
|
+
|
|
520
|
+
hooks = _load_hooks_json(console, target.path)
|
|
521
|
+
if not hooks:
|
|
522
|
+
console.print(
|
|
523
|
+
"[yellow]No hooks found in this file. Use /hooks add to create one.[/yellow]"
|
|
524
|
+
)
|
|
525
|
+
return True
|
|
526
|
+
|
|
527
|
+
event_options = list(hooks.keys())
|
|
528
|
+
event_name = _prompt_event_selection(console, event_options, event_options[0])
|
|
529
|
+
if not event_name:
|
|
530
|
+
return True
|
|
531
|
+
|
|
532
|
+
matchers = hooks.get(event_name, [])
|
|
533
|
+
matcher = _prompt_matcher_selection(console, event_name, matchers)
|
|
534
|
+
if matcher is None:
|
|
535
|
+
return True
|
|
536
|
+
|
|
537
|
+
hook_idx = _select_hook(console, matcher)
|
|
538
|
+
if hook_idx is None:
|
|
539
|
+
return True
|
|
540
|
+
|
|
541
|
+
existing_hook = matcher.get("hooks", [])[hook_idx]
|
|
542
|
+
updated_hook = _prompt_hook_details(console, event_name, existing_hook)
|
|
543
|
+
if not updated_hook:
|
|
544
|
+
return True
|
|
545
|
+
|
|
546
|
+
matcher["hooks"][hook_idx] = updated_hook
|
|
547
|
+
if not _save_hooks_json(console, target.path, hooks):
|
|
548
|
+
return True
|
|
549
|
+
|
|
550
|
+
console.print(
|
|
551
|
+
f"[green]✓ Updated hook in {escape(str(target.path))} ({escape(event_name)}).[/green]"
|
|
552
|
+
)
|
|
553
|
+
return True
|
|
554
|
+
|
|
555
|
+
|
|
556
|
+
def _handle_delete(ui: Any, tokens: List[str], project_path: Path) -> bool:
|
|
557
|
+
"""Handle /hooks delete."""
|
|
558
|
+
console = ui.console
|
|
559
|
+
target = _select_target(console, project_path, tokens[0] if tokens else None)
|
|
560
|
+
if not target:
|
|
561
|
+
return True
|
|
562
|
+
|
|
563
|
+
hooks = _load_hooks_json(console, target.path)
|
|
564
|
+
if not hooks:
|
|
565
|
+
console.print("[yellow]No hooks to delete.[/yellow]")
|
|
566
|
+
return True
|
|
567
|
+
|
|
568
|
+
event_options = list(hooks.keys())
|
|
569
|
+
event_name = _prompt_event_selection(console, event_options, event_options[0])
|
|
570
|
+
if not event_name:
|
|
571
|
+
return True
|
|
572
|
+
|
|
573
|
+
matchers = hooks.get(event_name, [])
|
|
574
|
+
matcher = _prompt_matcher_selection(console, event_name, matchers)
|
|
575
|
+
if matcher is None:
|
|
576
|
+
return True
|
|
577
|
+
|
|
578
|
+
hook_idx = _select_hook(console, matcher)
|
|
579
|
+
if hook_idx is None:
|
|
580
|
+
return True
|
|
581
|
+
|
|
582
|
+
confirmation = console.input("Delete this hook? [Y/n]: ").strip().lower()
|
|
583
|
+
if confirmation not in ("", "y", "yes"):
|
|
584
|
+
console.print("[yellow]Delete cancelled.[/yellow]")
|
|
585
|
+
return True
|
|
586
|
+
|
|
587
|
+
matcher["hooks"].pop(hook_idx)
|
|
588
|
+
if not matcher["hooks"]:
|
|
589
|
+
matchers.remove(matcher)
|
|
590
|
+
if not matchers:
|
|
591
|
+
hooks.pop(event_name, None)
|
|
592
|
+
|
|
593
|
+
if not _save_hooks_json(console, target.path, hooks):
|
|
594
|
+
return True
|
|
595
|
+
|
|
596
|
+
console.print(
|
|
597
|
+
f"[green]✓ Deleted hook from {escape(str(target.path))} ({escape(event_name)}).[/green]"
|
|
598
|
+
)
|
|
599
|
+
return True
|
|
600
|
+
|
|
601
|
+
|
|
602
|
+
def _handle(ui: Any, arg: str) -> bool:
|
|
603
|
+
"""Entry point for /hooks command."""
|
|
604
|
+
project_path = getattr(ui, "project_path", None) or Path.cwd()
|
|
605
|
+
tokens = arg.split()
|
|
606
|
+
subcmd = tokens[0].lower() if tokens else ""
|
|
607
|
+
|
|
608
|
+
if subcmd in ("help", "-h", "--help"):
|
|
609
|
+
_print_usage(ui.console)
|
|
610
|
+
return True
|
|
611
|
+
|
|
612
|
+
if subcmd in ("add", "create", "new"):
|
|
613
|
+
return _handle_add(ui, tokens[1:], project_path)
|
|
614
|
+
|
|
615
|
+
if subcmd in ("edit", "update"):
|
|
616
|
+
return _handle_edit(ui, tokens[1:], project_path)
|
|
617
|
+
|
|
618
|
+
if subcmd in ("delete", "del", "remove", "rm"):
|
|
619
|
+
return _handle_delete(ui, tokens[1:], project_path)
|
|
620
|
+
|
|
621
|
+
if subcmd:
|
|
622
|
+
ui.console.print(f"[red]Unknown hooks subcommand '{escape(subcmd)}'.[/red]")
|
|
623
|
+
_print_usage(ui.console)
|
|
624
|
+
return True
|
|
625
|
+
|
|
626
|
+
return _render_hooks_overview(ui, project_path)
|
|
627
|
+
|
|
628
|
+
|
|
629
|
+
command = SlashCommand(
|
|
630
|
+
name="hooks",
|
|
631
|
+
description="Show configured hooks and manage them",
|
|
632
|
+
handler=_handle,
|
|
633
|
+
)
|
|
634
|
+
|
|
635
|
+
|
|
636
|
+
__all__ = ["command"]
|