"""Shell completion installation utilities. This module handles the installation of completion scripts to shell-specific locations and the updating of shell RC files to load completions. """ import os import sys from collections.abc import Callable from pathlib import Path from typing import Annotated, Literal from cyclopts.parameter import Parameter def _detect_omz_completions_dir() -> Path | None: """Detect oh-my-zsh custom completions directory. Uses ``$ZSH_CUSTOM/completions`` (the recommended location for user completions in oh-my-zsh). Falls back to ``$ZSH/custom/completions`` when ``$ZSH_CUSTOM`` is not set. Returns ------- Path | None Path to the oh-my-zsh custom completions directory, or None if oh-my-zsh is not detected. """ zsh_custom_str = os.environ.get("ZSH_CUSTOM") if zsh_custom_str: zsh_custom = Path(zsh_custom_str) if zsh_custom.is_dir(): return zsh_custom / "completions" zsh_dir_str = os.environ.get("ZSH") if not zsh_dir_str: return None zsh_dir = Path(zsh_dir_str) if zsh_dir.is_dir(): return zsh_dir / "custom" / "completions" return None def get_default_completion_path(shell: Literal["zsh", "bash", "fish"], prog_name: str) -> Path: """Get the default completion script path for a given shell. Parameters ---------- shell : Literal["zsh", "bash", "fish"] Shell type. prog_name : str Program name for the completion script. Returns ------- Path Default installation path for the shell. Raises ------ ValueError If shell type is unsupported. """ home = Path.home() if shell == "zsh": omz_completions = _detect_omz_completions_dir() if omz_completions is not None: omz_completions.mkdir(parents=True, exist_ok=True) return omz_completions / f"_{prog_name}" # Vanilla zsh fallback zsh_completions = home / ".zsh" / "completions" zsh_completions.mkdir(parents=True, exist_ok=True) return zsh_completions / f"_{prog_name}" elif shell == "bash": bash_completions = home / ".local" / "share" / "bash-completion" / "completions" bash_completions.mkdir(parents=True, exist_ok=True) return bash_completions / prog_name elif shell == "fish": fish_completions = home / ".config" / "fish" / "completions" fish_completions.mkdir(parents=True, exist_ok=True) return fish_completions / f"{prog_name}.fish" else: raise ValueError(f"Unsupported shell: {shell}") def add_to_rc_file(script_path: Path, prog_name: str, shell: Literal["bash", "zsh"]) -> bool: """Add completion configuration to shell RC file. For bash, adds a source line to load the completion script. For zsh, adds the completion directory to fpath so compinit can find it. Parameters ---------- script_path : Path Path to the completion script. prog_name : str Program name for display in comments. shell : Literal["bash", "zsh"] Shell type. Returns ------- bool True if configuration was added, False if it already existed or on error. """ if shell == "bash": rc_file = Path.home() / ".bashrc" config_line = f'[ -f "{script_path}" ] && . "{script_path}"' comment = f"# Load {prog_name} completion" elif shell == "zsh": if _detect_omz_completions_dir(): return False rc_file = Path.home() / ".zshrc" completion_dir = script_path.parent config_line = f"fpath=({completion_dir} $fpath)" comment = f"# {prog_name} completions" else: raise NotImplementedError rc_file = rc_file.resolve() if rc_file.exists(): content = rc_file.read_text() # For zsh, check if this directory is already in fpath configuration # For bash, check if the exact source line exists if shell == "zsh" and str(script_path.parent) in content and "fpath=" in content: return False elif config_line in content: return False else: content = "" if shell == "zsh": # Prepend to ensure fpath is set before any compinit call rc_file.write_text(f"{comment}\n{config_line}\n{content}") else: # Bash: append needs_newline = content and not content.endswith("\n") with rc_file.open("a") as f: if needs_newline: f.write("\n") f.write(f"{comment}\n{config_line}\n") return True def create_install_completion_command( install_completion_fn: Callable[..., Path], add_to_startup: bool, ): """Create a command function for installing shell completion. Parameters ---------- install_completion_fn : Callable Function that performs the actual installation (typically App.install_completion). Should accept (shell, output, add_to_startup) and return the installation path. add_to_startup : bool Whether to add source line to shell RC file. Returns ------- Callable Command function that can be registered with App.command(). """ def _install_completion_command( *, shell: Annotated[Literal["zsh", "bash", "fish"] | None, Parameter()] = None, output: Annotated[Path | None, Parameter(name=["-o", "--output"])] = None, ): """Install shell completion for this application. This command generates and installs the completion script to the appropriate location for your shell. After installation, you may need to restart your shell or source your shell configuration file. Parameters ---------- shell : Literal["zsh", "bash", "fish"] | None Shell type for completion. If not specified, attempts to auto-detect current shell. output : Path | None Output path for the completion script. If not specified, uses shell-specific default. """ from cyclopts.completion.detect import ShellDetectionError, detect_shell if shell is None: try: shell = detect_shell() except ShellDetectionError: print( "Could not auto-detect shell. Please specify --shell explicitly.", file=sys.stderr, ) sys.exit(1) install_path = install_completion_fn(shell=shell, output=output, add_to_startup=add_to_startup) print(f"✓ Completion script installed to {install_path}") if shell == "zsh": if _detect_omz_completions_dir(): print("✓ Detected oh-my-zsh: completions directory is already in $fpath.") print("\nRestart your shell or run: exec zsh") elif add_to_startup: zshrc = Path.home() / ".zshrc" completion_dir = install_path.parent print(f"✓ Added {completion_dir} to fpath in {zshrc}") print("\nNote: Ensure compinit is configured in your .zshrc (most zsh setups already have this).") print("Restart your shell or run: exec zsh") else: completion_dir = install_path.parent print(f"\nTo enable completions, ensure {completion_dir} is in your $fpath.") print("Add this to your ~/.zshrc or ~/.zprofile if not already present:") print(f" fpath=({completion_dir} $fpath)") print(" autoload -Uz compinit && compinit") print("\nThen restart your shell or run: exec zsh") elif shell == "bash": if add_to_startup: bashrc = Path.home() / ".bashrc" print(f"✓ Added completion loader to {bashrc}") print("\nRestart your shell or run: source ~/.bashrc") else: print("\nCompletions will be automatically loaded by bash-completion.") print("If completions don't work:") print(" 1. Ensure bash-completion is installed (v2.8+)") print(" 2. Restart your shell or run: exec bash") print("\nNote: bash-completion is typically installed via:") print(" - macOS: brew install bash-completion@2") print(" - Debian/Ubuntu: apt install bash-completion") print(" - Fedora/RHEL: dnf install bash-completion") elif shell == "fish": print("\nCompletions are automatically loaded in fish.") print("Restart your shell or run: source ~/.config/fish/config.fish") else: raise NotImplementedError return _install_completion_command