Skip to content

Commit 7f1100e

Browse files
authored
Merge pull request #46 from alex-feel/alex-feel-dev
Fix hooks
2 parents 79f1f63 + 24cd05c commit 7f1100e

File tree

7 files changed

+395
-80
lines changed

7 files changed

+395
-80
lines changed

environments/examples/python.yaml

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -27,10 +27,11 @@ slash-commands:
2727
output-styles:
2828
# Add output styles if needed
2929
hooks:
30-
- event: PostToolUse
31-
matcher: Edit|MultiEdit|Write
32-
type: command
33-
command: .claude/hooks/python_ruff_lint.py
34-
files:
30+
files:
3531
- hooks/examples/python_ruff_lint.py
32+
events:
33+
- event: PostToolUse
34+
matcher: Edit|MultiEdit|Write
35+
type: command
36+
command: python_ruff_lint.py
3637
system-prompt: system-prompts/examples/python-developer.md

environments/templates/basic-template.yaml

Lines changed: 22 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -58,18 +58,29 @@ output-styles:
5858

5959
# Hooks for automatic actions (optional)
6060
hooks:
61-
# Example: Run linter on file changes
62-
# - event: PostToolUse
63-
# matcher: Edit|MultiEdit|Write # Regex pattern
64-
# type: command
65-
# command: .claude/hooks/your-script.py
66-
# files: # Files to download for this hook
67-
# - hooks/examples/your-script.py
61+
# Files to download for all hooks (listed once, used by multiple events)
62+
files:
63+
# - hooks/examples/your-script.py
64+
# - hooks/examples/another-script.py
6865

69-
# Example: Notification hook
70-
# - event: Notification
71-
# type: command
72-
# command: notify-send 'Claude Code' 'Task Complete'
66+
# Hook events configuration
67+
events:
68+
# Example: Run linter on file changes
69+
# - event: PostToolUse
70+
# matcher: Edit|MultiEdit|Write # Regex pattern
71+
# type: command
72+
# command: your-script.py # Reference the filename from 'files' section above
73+
74+
# Example: Type checking on Python files
75+
# - event: PostToolUse
76+
# matcher: "\.py$" # Only Python files
77+
# type: command
78+
# command: another-script.py
79+
80+
# Example: Notification hook
81+
# - event: Notification
82+
# type: command
83+
# command: notify-send 'Claude Code' 'Task Complete'
7384

7485
# System prompt file (required)
7586
# Path is relative to the repository root

hooks/README.md

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -179,16 +179,40 @@ Hooks have access to standard environment variables plus:
179179

180180
### Chaining Hooks
181181
Multiple hooks can run for the same event:
182+
183+
**In environment YAML configuration:**
184+
```yaml
185+
hooks:
186+
files:
187+
- hooks/examples/format_check.py
188+
- hooks/examples/type_check.py
189+
- hooks/examples/run_tests.py
190+
events:
191+
- event: PostToolUse
192+
matcher: "\.py$"
193+
type: command
194+
command: format_check.py
195+
- event: PostToolUse
196+
matcher: "\.py$"
197+
type: command
198+
command: type_check.py
199+
- event: PostToolUse
200+
matcher: "\.py$"
201+
type: command
202+
command: run_tests.py
203+
```
204+
205+
**Resulting settings.json:**
182206
```json
183207
{
184208
"hooks": {
185209
"PostToolUse": [
186210
{
187211
"matcher": "\.py$",
188212
"hooks": [
189-
{"type": "command", "command": "black --check"},
190-
{"type": "command", "command": "mypy"},
191-
{"type": "command", "command": "pytest"}
213+
{"type": "command", "command": "py C:/Users/user/.claude/hooks/format_check.py"},
214+
{"type": "command", "command": "py C:/Users/user/.claude/hooks/type_check.py"},
215+
{"type": "command", "command": "py C:/Users/user/.claude/hooks/run_tests.py"}
192216
]
193217
}
194218
]

scripts/setup-environment.py

Lines changed: 65 additions & 61 deletions
Original file line numberDiff line numberDiff line change
@@ -464,7 +464,7 @@ def configure_all_mcp_servers(servers: list[dict[str, Any]]) -> bool:
464464
return True
465465

466466

467-
def create_additional_settings(hooks: list[dict[str, Any]], claude_user_dir: Path) -> bool:
467+
def create_additional_settings(hooks: dict[str, Any], claude_user_dir: Path) -> bool:
468468
"""Create additional-settings.json with environment-specific hooks.
469469
470470
This file is always overwritten to avoid duplicate hooks when re-running the installer.
@@ -492,33 +492,32 @@ def create_additional_settings(hooks: list[dict[str, Any]], claude_user_dir: Pat
492492
'hooks': {},
493493
}
494494

495-
# Process each hook
496-
for hook in hooks:
497-
# Ensure hook is a dict (should be with PyYAML)
498-
if not isinstance(hook, dict):
499-
warning(f'Unexpected hook format: {type(hook)} - {hook}')
500-
continue
495+
# Extract files and events from the hooks configuration
496+
hook_files = hooks.get('files', [])
497+
hook_events = hooks.get('events', [])
498+
499+
# Download all hook files first
500+
if hook_files:
501+
hooks_dir = claude_user_dir / 'hooks'
502+
hooks_dir.mkdir(parents=True, exist_ok=True)
503+
for file in hook_files:
504+
url = f'{REPO_BASE_URL}/{file}'
505+
filename = Path(file).name
506+
destination = hooks_dir / filename
507+
download_file(url, destination)
508+
509+
# Process each hook event
510+
for hook in hook_events:
501511

502512
event = hook.get('event')
503513
matcher = hook.get('matcher', '')
504514
hook_type = hook.get('type', 'command')
505515
command = hook.get('command')
506-
files = hook.get('files', [])
507516

508517
if not event or not command:
509518
warning('Invalid hook configuration, skipping')
510519
continue
511520

512-
# Download hook files if specified
513-
if files:
514-
hooks_dir = claude_user_dir / 'hooks'
515-
hooks_dir.mkdir(parents=True, exist_ok=True)
516-
for file in files:
517-
url = f'{REPO_BASE_URL}/{file}'
518-
filename = Path(file).name
519-
destination = hooks_dir / filename
520-
download_file(url, destination)
521-
522521
# Add to settings
523522
if event not in settings['hooks']:
524523
settings['hooks'][event] = []
@@ -614,32 +613,14 @@ def create_launcher_script(claude_user_dir: Path, command_name: str, system_prom
614613
exit 1
615614
}}
616615
617-
# Build the bash command with all arguments
616+
# Call the shared script instead of building complex command
617+
$scriptPath = Join-Path $claudeUserDir "launch-{command_name}.sh"
618+
618619
if ($args.Count -gt 0) {{
619620
Write-Host "Passing additional arguments: $args" -ForegroundColor Cyan
620-
# Properly escape arguments for bash (quote args with spaces)
621-
$escapedArgs = @()
622-
foreach ($arg in $args) {{
623-
if ($arg -match ' ') {{
624-
# If arg contains spaces, wrap in single quotes for bash
625-
$escapedArgs += "'$arg'"
626-
}} else {{
627-
$escapedArgs += $arg
628-
}}
629-
}}
630-
$argsString = $escapedArgs -join ' '
631-
# Use a here-string to avoid complex quote escaping
632-
$bashCommand = @"
633-
p=`$(tr -d '\\\\r' < ~/.claude/prompts/{system_prompt_file}); s=~/.claude/additional-settings.json; \\
634-
exec claude --append-system-prompt=\\"`$p\\" --settings=\\"`$s\\" $argsString
635-
"@
636-
& $bashPath -lc $bashCommand
621+
& $bashPath --login $scriptPath @args
637622
}} else {{
638-
# Use --% to stop PowerShell parsing, with literal command string
639-
$bashCommand = "p=`$(tr -d '\\r' < ~/.claude/prompts/{system_prompt_file}); " + `
640-
"s=~/.claude/additional-settings.json; " + `
641-
"exec claude --append-system-prompt=\\`"`$p\\`" --settings=\\`"`$s\\`""
642-
& $bashPath --% -lc $bashCommand
623+
& $bashPath --login $scriptPath
643624
}}
644625
'''
645626
launcher_path.write_text(launcher_content)
@@ -660,19 +641,45 @@ def create_launcher_script(claude_user_dir: Path, command_name: str, system_prom
660641
661642
echo Starting Claude Code with {command_name} configuration...
662643
663-
REM Build the command with all arguments
664-
set BASH_EXE="C:\\Program Files\\Git\\bin\\bash.exe"
665-
set CMD_PREFIX=p=$(tr -d '\\r' ^< ~/.claude/prompts/{system_prompt_file})
666-
set SETTINGS_PATH=~/.claude/additional-settings.json
644+
REM Call shared script instead of complex command
645+
set "BASH_EXE=C:\\Program Files\\Git\\bin\\bash.exe"
646+
if not exist "%BASH_EXE%" set "BASH_EXE=C:\\Program Files (x86)\\Git\\bin\\bash.exe"
647+
648+
set "SCRIPT_WIN=%USERPROFILE%\\.claude\\launch-{command_name}.sh"
649+
667650
if "%~1"=="" (
668-
%BASH_EXE% -lc "%CMD_PREFIX%; exec claude --append-system-prompt=\\"$p\\" --settings=\\"%SETTINGS_PATH%\\""
651+
"%BASH_EXE%" --login "%SCRIPT_WIN%"
669652
) else (
670653
echo Passing additional arguments: %*
671-
%BASH_EXE% -lc "%CMD_PREFIX%; exec claude --append-system-prompt=\\"$p\\" --settings=\\"%SETTINGS_PATH%\\" %*"
654+
"%BASH_EXE%" --login "%SCRIPT_WIN%" %*
672655
)
673656
'''
674657
batch_path.write_text(batch_content)
675658

659+
# Create shared POSIX script that actually launches Claude
660+
shared_sh = claude_user_dir / f'launch-{command_name}.sh'
661+
shared_sh_content = f'''#!/usr/bin/env bash
662+
set -euo pipefail
663+
664+
PROMPT_PATH="$HOME/.claude/prompts/{system_prompt_file}"
665+
if [ ! -f "$PROMPT_PATH" ]; then
666+
echo "Error: System prompt not found at $PROMPT_PATH" >&2
667+
exit 1
668+
fi
669+
670+
# Read prompt and get Windows path for settings
671+
PROMPT_CONTENT=$(tr -d '\\r' < "$PROMPT_PATH")
672+
# Try to get Windows path format; fallback to Unix path if cygpath is not available
673+
SETTINGS_WIN="$(cygpath -m "$HOME/.claude/additional-settings.json" 2>/dev/null ||
674+
echo "$HOME/.claude/additional-settings.json")"
675+
676+
exec claude --append-system-prompt "$PROMPT_CONTENT" --settings "$SETTINGS_WIN" "$@"
677+
'''
678+
shared_sh.write_text(shared_sh_content, newline='\n')
679+
# Make it executable for bash
680+
with contextlib.suppress(Exception):
681+
shared_sh.chmod(0o755)
682+
676683
else:
677684
# Create bash launcher for Unix-like systems
678685
launcher_path = launcher_path.with_suffix('.sh')
@@ -731,13 +738,13 @@ def register_global_command(launcher_path: Path, command_name: str, system_promp
731738
batch_path = local_bin / f'{command_name}.cmd'
732739
batch_content = f'''@echo off
733740
REM Global {command_name} command for CMD
734-
set BASH_EXE="C:\\Program Files\\Git\\bin\\bash.exe"
735-
set CMD_PREFIX=p=$(tr -d '\\r' ^< ~/.claude/prompts/{system_prompt_file})
736-
set SETTINGS_PATH=~/.claude/additional-settings.json
741+
set "BASH_EXE=C:\\Program Files\\Git\\bin\\bash.exe"
742+
if not exist "%BASH_EXE%" set "BASH_EXE=C:\\Program Files (x86)\\Git\\bin\\bash.exe"
743+
set "SCRIPT_WIN=%USERPROFILE%\\.claude\\launch-{command_name}.sh"
737744
if "%~1"=="" (
738-
%BASH_EXE% -lc "%CMD_PREFIX%; exec claude --append-system-prompt=\\"$p\\" --settings=\\"%SETTINGS_PATH%\\""
745+
"%BASH_EXE%" --login "%SCRIPT_WIN%"
739746
) else (
740-
%BASH_EXE% -lc "%CMD_PREFIX%; exec claude --append-system-prompt=\\"$p\\" --settings=\\"%SETTINGS_PATH%\\" %*"
747+
"%BASH_EXE%" --login "%SCRIPT_WIN%" %*
741748
)
742749
'''
743750
batch_path.write_text(batch_content)
@@ -767,14 +774,14 @@ def register_global_command(launcher_path: Path, command_name: str, system_promp
767774
768775
# Read the prompt content and pass it directly to claude
769776
PROMPT_CONTENT=$(cat "$PROMPT_PATH")
770-
SETTINGS_PATH="$HOME/.claude/additional-settings.json"
777+
SETTINGS_WIN="$(cygpath -m "$HOME/.claude/additional-settings.json")"
771778
772779
# Pass all arguments to claude with the system prompt
773780
if [ $# -gt 0 ]; then
774781
echo "Passing additional arguments: $@"
775-
claude --append-system-prompt "$PROMPT_CONTENT" --settings "$SETTINGS_PATH" "$@"
782+
claude --settings "$SETTINGS_WIN" --append-system-prompt "$PROMPT_CONTENT" "$@"
776783
else
777-
claude --append-system-prompt "$PROMPT_CONTENT" --settings "$SETTINGS_PATH"
784+
claude --settings "$SETTINGS_WIN" --append-system-prompt "$PROMPT_CONTENT"
778785
fi
779786
'''
780787
bash_wrapper_path.write_text(bash_content, newline='\n') # Use Unix line endings
@@ -931,11 +938,8 @@ def main() -> None:
931938
# Step 9: Configure hooks
932939
print()
933940
print(f'{Colors.CYAN}Step 9: Configuring hooks...{Colors.NC}')
934-
hooks = config.get('hooks', [])
935-
if hooks:
936-
create_additional_settings(hooks, claude_user_dir)
937-
else:
938-
info('No hooks configured')
941+
hooks = config.get('hooks', {})
942+
create_additional_settings(hooks, claude_user_dir)
939943

940944
# Step 10: Create launcher script
941945
print()
@@ -966,7 +970,7 @@ def main() -> None:
966970
print(f' * Output styles: {len(output_styles) if output_styles else 0} installed')
967971
print(' * System prompt: Configured')
968972
print(f' * MCP servers: {len(mcp_servers)} configured')
969-
print(f' * Hooks: {len(hooks) if hooks else 0} configured')
973+
print(f' * Hooks: {len(hooks.get("events", [])) if hooks else 0} configured')
970974
print(f' * Global command: {command_name} registered')
971975

972976
print()

technical-docs/APPEND_SYSTEM_PROMPT_CMD.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
> **Note**: This document explains the CMD one-liner approach for directly passing system prompts. For a more robust solution that handles additional flags like `--settings` and works consistently across all Windows shells, see [SHARED_POSIX_SCRIPT_APPROACH.md](SHARED_POSIX_SCRIPT_APPROACH.md).
2+
13
Here is a precise, end-to-end explanation of why the **CMD** one-liner works and what each part does:
24

35
```cmd

technical-docs/APPEND_SYSTEM_PROMPT_POWERSHELL.md

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,3 +1,5 @@
1+
> **Note**: This document explains the PowerShell one-liner approach for directly passing system prompts. For a more robust solution that handles additional flags like `--settings` and works consistently across all Windows shells, see [SHARED_POSIX_SCRIPT_APPROACH.md](SHARED_POSIX_SCRIPT_APPROACH.md).
2+
13
Here is a precise, end-to-end explanation of what every character in your working line does, why earlier attempts failed, and why this one succeeds.
24

35
```powershell

0 commit comments

Comments
 (0)