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,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"]