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