@@ -414,6 +414,9 @@ def select_sections(has_existing_config: bool) -> dict[str, bool]:
414414 "subprocess" : not has_existing_config ,
415415 }
416416
417+ if has_existing_config and not Confirm .ask ("Make targeted edits to specific sections?" , default = False ):
418+ return {key : False for key in defaults }
419+
417420 prompts = [
418421 ("languages" , "Edit language enforcement settings?" ),
419422 ("phases" , "Edit phases (auto_format, subprocess_delegation)?" ),
@@ -456,6 +459,38 @@ def _ensure_local_bin_on_path(show_hint: bool = False) -> bool:
456459 return True
457460
458461
462+ def _ensure_pip_user_bin_on_path () -> bool :
463+ """Ensure macOS pip --user script dirs are on PATH.
464+
465+ pip --user installs scripts to ~/Library/Python/<version>/bin on macOS
466+ (system Python), unlike ~/.local/bin on Linux. Glob all version dirs.
467+ """
468+ macos_pip_base = Path .home () / "Library" / "Python"
469+ if not macos_pip_base .exists ():
470+ return False
471+
472+ def _version_key (p : Path ) -> tuple [int , ...]:
473+ try :
474+ return tuple (int (x ) for x in p .parent .name .split ("." ))
475+ except ValueError :
476+ return (0 ,)
477+
478+ # Sort descending so highest version (e.g. 3.13) gets highest PATH priority
479+ all_bin_dirs = sorted (macos_pip_base .glob ("*/bin" ), key = _version_key , reverse = True )
480+ if not all_bin_dirs :
481+ return False
482+
483+ bin_dir_strs = {str (d ) for d in all_bin_dirs }
484+ current_path = os .environ .get ("PATH" , "" )
485+ remaining = [e for e in current_path .split (os .pathsep ) if e not in bin_dir_strs ]
486+ new_path = os .pathsep .join ([str (d ) for d in all_bin_dirs ] + remaining )
487+
488+ if current_path == new_path :
489+ return False
490+ os .environ ["PATH" ] = new_path
491+ return True
492+
493+
459494def _detect_linux_package_manager () -> str | None :
460495 for manager in ("apt-get" , "dnf" , "yum" , "pacman" , "apk" , "zypper" ):
461496 if shutil .which (manager ):
@@ -619,6 +654,33 @@ def _install_jaq() -> bool: # noqa: PLR0911
619654 return False
620655
621656
657+ def _install_pre_commit () -> bool :
658+ """Attempt to install pre-commit using available package tooling."""
659+ install_commands : list [tuple [list [str ], str ]] = []
660+ if shutil .which ("uv" ):
661+ install_commands .append ((["uv" , "tool" , "install" , "pre-commit" ], "Installing pre-commit via uv tool" ))
662+ if shutil .which ("pipx" ):
663+ install_commands .append ((["pipx" , "install" , "pre-commit" ], "Installing pre-commit via pipx" ))
664+
665+ # pip --user is acceptable here: the setup wizard bootstraps tooling
666+ # before hooks are active, so enforce_package_managers won't block it.
667+ python_cmd = shutil .which ("python3" ) or shutil .which ("python" )
668+ if python_cmd :
669+ install_commands .append (
670+ ([python_cmd , "-m" , "pip" , "install" , "--user" , "pre-commit" ], "Installing pre-commit via pip --user" )
671+ )
672+
673+ for command , description in install_commands :
674+ if not _run_install_command (command , description ):
675+ continue
676+ _ensure_local_bin_on_path (show_hint = True )
677+ if "--user" in command :
678+ _ensure_pip_user_bin_on_path ()
679+ if shutil .which ("pre-commit" ):
680+ return True
681+ return False
682+
683+
622684def _guided_install_missing_tools (missing_required : list [str ]) -> list [str ]:
623685 if not missing_required :
624686 return []
@@ -662,14 +724,19 @@ def check_tools():
662724 console .print ("[bold blue]Checking System Dependencies...[/bold blue]" )
663725 _ensure_local_bin_on_path ()
664726 missing_required = []
727+ required_tools = list (REQUIRED_TOOLS .keys ())
728+ console .print (f" Required tools: { ', ' .join (required_tools )} " )
729+ found_required = 0
665730
666731 for tool , desc in REQUIRED_TOOLS .items ():
667732 path = shutil .which (tool )
668733 if path :
669734 console .print (f" [green]✓[/green] { tool } found at { path } " )
735+ found_required += 1
670736 else :
671737 console .print (f" [red]✗[/red] { tool } NOT found. { desc } " )
672738 missing_required .append (tool )
739+ console .print (f" Required tool status: { found_required } /{ len (required_tools )} present" )
673740
674741 if missing_required :
675742 missing_required = _guided_install_missing_tools (missing_required )
@@ -953,6 +1020,40 @@ def configure_selected_sections(
9531020 return generated
9541021
9551022
1023+ def _install_pre_commit_hooks () -> None :
1024+ console .print (" Installing pre-commit hooks..." )
1025+ try :
1026+ subprocess .run (["pre-commit" , "install" ], check = True , capture_output = True ) # noqa: S607 # nosec B603 B607
1027+ console .print (" [green]✓[/green] pre-commit hooks installed" )
1028+ except FileNotFoundError :
1029+ console .print (" [red]✗[/red] pre-commit install failed: binary not found in PATH" )
1030+ except subprocess .CalledProcessError as exc :
1031+ detail = (exc .stderr or b"" ).decode ("utf-8" , errors = "replace" ).strip ()
1032+ console .print (f" [red]✗[/red] pre-commit install failed{ ': ' + detail if detail else '' } " )
1033+
1034+
1035+ def _ensure_pre_commit_ready () -> None :
1036+ if not Path (".pre-commit-config.yaml" ).exists ():
1037+ return
1038+
1039+ if shutil .which ("pre-commit" ):
1040+ _install_pre_commit_hooks ()
1041+ return
1042+
1043+ console .print (" [yellow]![/yellow] .pre-commit-config.yaml found but 'pre-commit' not installed." )
1044+ if not Confirm .ask ("Install pre-commit now?" , default = True ):
1045+ console .print (" [yellow]![/yellow] Skipping pre-commit installation." )
1046+ return
1047+
1048+ if not _install_pre_commit ():
1049+ console .print (" [red]✗[/red] Could not install pre-commit automatically." )
1050+ console .print (" [yellow]Manual:[/yellow] uv tool install pre-commit" )
1051+ return
1052+
1053+ console .print (" [green]✓[/green] pre-commit installed" )
1054+ _install_pre_commit_hooks ()
1055+
1056+
9561057def setup_hooks ():
9571058 """Ensure hooks directory exists and scripts are executable."""
9581059 console .print ("\n [bold blue]Setting up Hooks...[/bold blue]" )
@@ -971,17 +1072,7 @@ def setup_hooks():
9711072 os .chmod (script , 0o755 ) # noqa: S103 # nosec B103
9721073 console .print (f" [green]✓[/green] chmod +x { script .name } " )
9731074
974- # Check pre-commit
975- if Path (".pre-commit-config.yaml" ).exists ():
976- if shutil .which ("pre-commit" ):
977- console .print (" Installing pre-commit hooks..." )
978- try :
979- subprocess .run (["pre-commit" , "install" ], check = True ) # noqa: S607 # nosec B603 B607
980- console .print (" [green]✓[/green] pre-commit installed" )
981- except subprocess .CalledProcessError :
982- console .print (" [red]✗[/red] pre-commit install failed" )
983- else :
984- console .print (" [yellow]![/yellow] .pre-commit-config.yaml found but 'pre-commit' not installed." )
1075+ _ensure_pre_commit_ready ()
9851076
9861077
9871078@app .command ()
@@ -1005,7 +1096,7 @@ def main():
10051096 new_config : dict [str , Any ] | None = None
10061097 if not any (section_selection .values ()):
10071098 if existing_config :
1008- console .print (" [yellow]![/yellow] No sections selected; existing configuration will be kept unchanged." )
1099+ console .print (" Existing configuration will be kept unchanged." )
10091100 else :
10101101 console .print (" [red]✗[/red] No sections selected and no existing config found; aborting." )
10111102 raise typer .Exit (code = 1 )
0 commit comments