Skip to content

Commit c37fdec

Browse files
authored
Merge pull request #132 from alex-feel/alex-feel-dev
Use uv run for Python hook execution
2 parents c8dc0a4 + d85f0ee commit c37fdec

File tree

3 files changed

+62
-40
lines changed

3 files changed

+62
-40
lines changed

scripts/setup_environment.py

Lines changed: 30 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -1946,40 +1946,39 @@ def create_additional_settings(
19461946
hooks_event_list: list[dict[str, Any]] = cast(list[dict[str, Any]], hooks_event_list_raw)
19471947
hooks_event_list.append(matcher_group)
19481948

1949-
# Build the proper command based on OS and file type
1950-
if command.endswith('.py'):
1951-
# Python script - need to handle cross-platform execution
1952-
# Use the absolute path to the downloaded hook file
1953-
# Strip query parameters from command if present
1954-
clean_command = command.split('?')[0] if '?' in command else command
1955-
hook_path = claude_user_dir / 'hooks' / Path(clean_command).name
1956-
python_cmd: str | None
1957-
1958-
if platform.system() == 'Windows':
1959-
# Windows needs explicit Python interpreter
1960-
# Use 'py' which is more reliable on Windows, fallback to 'python'
1961-
python_cmd = 'py' if shutil.which('py') else 'python'
1962-
# Use forward slashes for the path (works on Windows and avoids JSON escaping issues)
1949+
# Build the proper command based on file type
1950+
# Strip query parameters from command if present
1951+
clean_command = command.split('?')[0] if '?' in command else command
1952+
1953+
# Check if this looks like a file reference or a direct command
1954+
# File references typically don't contain spaces (just the filename)
1955+
# Direct commands like 'echo "test"' contain spaces
1956+
is_file_reference = ' ' not in clean_command
1957+
1958+
if is_file_reference:
1959+
# Determine if this is a Python script (case-insensitive check)
1960+
# Supports both .py and .pyw extensions
1961+
is_python_script = clean_command.lower().endswith(('.py', '.pyw'))
1962+
1963+
if is_python_script:
1964+
# Python script - use uv run for cross-platform execution
1965+
# Build absolute path to the hook file in .claude/hooks/
1966+
hook_path = claude_user_dir / 'hooks' / Path(clean_command).name
1967+
# Use POSIX-style path (forward slashes) for cross-platform compatibility
1968+
# This works on Windows, macOS, and Linux, and avoids JSON escaping issues
19631969
hook_path_str = hook_path.as_posix()
1964-
full_command = f'{python_cmd} {hook_path_str}'
1970+
# Use uv run with Python 3.12 - works cross-platform without PATH dependency
1971+
# uv automatically downloads Python 3.12 if not installed
1972+
# For .pyw files on Windows, uv automatically uses pythonw
1973+
full_command = f'uv run --python 3.12 {hook_path_str}'
19651974
else:
1966-
# Unix-like systems - explicitly use Python 3.12 for hooks
1967-
# This ensures compatibility with modern Python features (e.g., datetime.UTC)
1968-
python_cmd = 'python3.12' if shutil.which('python3.12') else None
1969-
1970-
if not python_cmd:
1971-
error(f'Python 3.12 required for hook "{command}" but not found in PATH')
1972-
error('Please install Python 3.12: uv python install 3.12')
1973-
# Continue without this hook rather than failing the entire setup
1974-
continue
1975-
1976-
# Make script executable
1977-
if hook_path.exists():
1978-
hook_path.chmod(0o755)
1979-
1980-
full_command = f'{python_cmd} {hook_path}'
1975+
# Other file - build absolute path and use as-is
1976+
# System will handle execution based on file extension (.sh, .bat, .cmd, .ps1, etc.)
1977+
hook_path = claude_user_dir / 'hooks' / Path(clean_command).name
1978+
hook_path_str = hook_path.as_posix()
1979+
full_command = hook_path_str
19811980
else:
1982-
# Not a Python script, use command as-is
1981+
# Direct command with spaces - use as-is
19831982
full_command = command
19841983

19851984
# Add hook configuration

tests/test_setup_environment.py

Lines changed: 12 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1197,10 +1197,12 @@ class TestMainFunction:
11971197
@patch('setup_environment.create_additional_settings')
11981198
@patch('setup_environment.create_launcher_script')
11991199
@patch('setup_environment.register_global_command')
1200+
@patch('setup_environment.is_admin', return_value=True)
12001201
@patch('pathlib.Path.mkdir')
12011202
def test_main_success(
12021203
self,
12031204
mock_mkdir,
1205+
mock_is_admin,
12041206
mock_register,
12051207
mock_launcher,
12061208
mock_settings,
@@ -1214,11 +1216,14 @@ def test_main_success(
12141216
"""Test successful main flow."""
12151217
# Verify mock configuration is available
12161218
assert mock_mkdir is not None
1219+
assert mock_is_admin.return_value is True
12171220
mock_load.return_value = (
12181221
{
12191222
'name': 'Test Environment',
12201223
'command-name': 'test-env',
1221-
'dependencies': ['npm install -g test'],
1224+
'dependencies': {
1225+
'windows': ['npm install -g test'],
1226+
},
12221227
'agents': ['agents/test.md'],
12231228
'slash-commands': ['commands/test.md'],
12241229
'mcp-servers': [{'name': 'test'}],
@@ -1252,8 +1257,10 @@ def test_main_no_config(self, mock_load):
12521257

12531258
@patch('setup_environment.load_config_from_source')
12541259
@patch('setup_environment.install_claude')
1255-
def test_main_install_failure(self, mock_install, mock_load):
1260+
@patch('setup_environment.is_admin', return_value=True)
1261+
def test_main_install_failure(self, mock_is_admin, mock_install, mock_load):
12561262
"""Test main with Claude installation failure."""
1263+
assert mock_is_admin.return_value is True # Verify admin check is mocked
12571264
mock_load.return_value = ({'name': 'Test'}, 'test.yaml')
12581265
mock_install.return_value = False
12591266

@@ -1263,8 +1270,10 @@ def test_main_install_failure(self, mock_install, mock_load):
12631270

12641271
@patch('setup_environment.load_config_from_source')
12651272
@patch('setup_environment.find_command')
1266-
def test_main_skip_install(self, mock_find, mock_load):
1273+
@patch('setup_environment.is_admin', return_value=True)
1274+
def test_main_skip_install(self, mock_is_admin, mock_find, mock_load):
12671275
"""Test main with --skip-install flag."""
1276+
assert mock_is_admin.return_value is True # Verify admin check is mocked
12681277
mock_load.return_value = (
12691278
{
12701279
'name': 'Test Environment',

tests/test_setup_environment_additional.py

Lines changed: 20 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -506,12 +506,14 @@ def test_install_claude_windows_ssl_error(self, mock_is_admin, mock_run, mock_ur
506506
assert mock_urlopen.call_count == 2
507507
mock_is_admin.assert_called() # Verify is_admin was called
508508

509+
@patch('setup_environment.is_admin', return_value=True)
509510
@patch('platform.system', return_value='Windows')
510511
@patch('setup_environment.urlopen')
511512
@patch('setup_environment.run_command')
512-
def test_install_claude_windows_failure(self, mock_run, mock_urlopen, mock_system):
513+
def test_install_claude_windows_failure(self, mock_run, mock_urlopen, mock_system, mock_is_admin):
513514
"""Test Claude installation failure on Windows."""
514515
assert mock_system.return_value == 'Windows'
516+
assert mock_is_admin.return_value is True
515517
mock_urlopen.return_value = MagicMock(read=lambda: b'# Script')
516518
mock_run.return_value = subprocess.CompletedProcess([], 1, '', 'Error')
517519

@@ -536,10 +538,12 @@ def test_install_claude_linux_success(self, mock_run, _mock_system):
536538
result = setup_environment.install_claude()
537539
assert result is True
538540

541+
@patch('setup_environment.is_admin', return_value=True)
539542
@patch('platform.system', return_value='Windows')
540543
@patch('setup_environment.urlopen')
541-
def test_install_claude_windows_network_error(self, mock_urlopen, _mock_system):
544+
def test_install_claude_windows_network_error(self, mock_urlopen, _mock_system, mock_is_admin):
542545
"""Test Claude installation with network error."""
546+
assert mock_is_admin.return_value is True
543547
mock_urlopen.side_effect = urllib.error.URLError('Network error')
544548

545549
result = setup_environment.install_claude()
@@ -905,9 +909,8 @@ def test_create_additional_settings_hooks_linux(self, _mock_system, mock_downloa
905909
)
906910

907911
assert result is True
908-
# Skip executable check on Windows as chmod doesn't work the same way
909-
if sys.platform != 'win32':
910-
assert hook_file.stat().st_mode & 0o111 # Check executable
912+
# Note: With uv run, executable permissions are no longer needed
913+
# The script is executed via: uv run --python 3.12 script.py
911914

912915
def test_create_additional_settings_hooks_invalid(self):
913916
"""Test handling invalid hook configuration."""
@@ -1122,6 +1125,7 @@ def test_main_skip_install_claude_not_found(self, mock_find, mock_load):
11221125
@patch('setup_environment.install_dependencies', return_value=True)
11231126
@patch('setup_environment.process_resources', return_value=True)
11241127
@patch('setup_environment.handle_resource', return_value=True)
1128+
@patch('setup_environment.is_admin', return_value=True)
11251129
@patch('setup_environment.configure_all_mcp_servers', return_value=True)
11261130
@patch('setup_environment.create_additional_settings', return_value=True)
11271131
@patch('setup_environment.create_launcher_script', return_value=None)
@@ -1132,6 +1136,7 @@ def test_main_no_launcher_created(
11321136
mock_launcher,
11331137
mock_settings,
11341138
mock_mcp,
1139+
mock_is_admin,
11351140
mock_handle_resource,
11361141
mock_process_resources,
11371142
mock_deps,
@@ -1140,6 +1145,7 @@ def test_main_no_launcher_created(
11401145
mock_load,
11411146
):
11421147
"""Test main when launcher creation fails."""
1148+
assert mock_is_admin.return_value is True
11431149
del _mock_mkdir # Unused but required for patch
11441150
del mock_launcher # Unused but required for patch
11451151
del mock_settings # Unused but required for patch
@@ -1163,6 +1169,7 @@ def test_main_no_launcher_created(
11631169
setup_environment.main()
11641170
mock_exit.assert_not_called() # Should continue despite launcher failure
11651171

1172+
@patch('setup_environment.is_admin', return_value=True)
11661173
@patch.dict('os.environ', {'CLAUDE_ENV_CONFIG': 'env-config'})
11671174
@patch('setup_environment.load_config_from_source')
11681175
@patch('setup_environment.install_claude', return_value=True)
@@ -1184,8 +1191,10 @@ def test_main_from_env_variable(
11841191
mock_deps,
11851192
mock_install,
11861193
mock_load,
1194+
mock_is_admin,
11871195
):
11881196
"""Test main using CLAUDE_ENV_CONFIG environment variable."""
1197+
assert mock_is_admin.return_value is True
11891198
del _mock_mkdir # Unused but required for patch
11901199
del mock_register # Unused but required for patch
11911200
del mock_settings # Unused but required for patch
@@ -1204,6 +1213,7 @@ def test_main_from_env_variable(
12041213
mock_exit.assert_not_called()
12051214
mock_load.assert_called_with('env-config', None)
12061215

1216+
@patch('setup_environment.is_admin', return_value=True)
12071217
@patch('setup_environment.load_config_from_source')
12081218
@patch('setup_environment.validate_all_config_files')
12091219
@patch('setup_environment.install_claude', return_value=True)
@@ -1228,8 +1238,10 @@ def test_main_with_all_features(
12281238
mock_install,
12291239
mock_validate,
12301240
mock_load,
1241+
mock_is_admin,
12311242
):
12321243
"""Test main with all configuration features enabled."""
1244+
assert mock_is_admin.return_value is True
12331245
del _mock_mkdir # Unused but required for patch
12341246
del mock_download_resource # Unused but required for patch
12351247

@@ -1252,7 +1264,9 @@ def test_main_with_all_features(
12521264
'API_KEY': 'test123',
12531265
'DEBUG': 'true',
12541266
},
1255-
'dependencies': ['npm install test'],
1267+
'dependencies': {
1268+
'windows': ['npm install test'],
1269+
},
12561270
'agents': ['agents/test.md'],
12571271
'slash-commands': ['commands/test.md'],
12581272
'output-styles': ['styles/test.md'],

0 commit comments

Comments
 (0)