import io import os from contextlib import redirect_stderr from dataclasses import dataclass from pathlib import Path from typing import Any import click import typer import typer.main from click.shell_completion import BashComplete, FishComplete, ZshComplete from cli.completion.providers import COMPLETION_PROVIDER_GATEWAY, CompletionProviderGateway from cli.completion.shells import PowerShellComplete, ensure_custom_completion_classes_registered from cli.exit_codes import ARGUMENT_ERROR, RUNTIME_ERROR from cli.metadata import COMPLETION_SHELL_SPEC_REGISTRY, CompletionShellSpec, CompletionShellSpecRegistry CLICK_COMPLETE_ENV_VAR = '_SHIERFENG_COMPLETE' class CompletionInstallerShellSupport: """ completion shell 差异支持对象。 该对象负责封装不同 shell 在脚本兼容处理和 source 命令构建上的差异, 供安装服务通过轻量 strategy 协作对象统一复用。 """ @staticmethod def keep_script_text(script_text: str) -> str: """ 保持脚本文本原样返回。 :param script_text: 原始脚本文本 :return: 原样脚本文本 """ return script_text @staticmethod def make_bash_completion_script_compatible(script_text: str) -> str: """ 对 Click 生成的 Bash completion 脚本做旧版本兼容处理。 兼容目标主要包括 macOS 默认 Bash 3.2: - `complete -o nosort` 在旧 Bash 上会在 `source` 阶段直接报错 - `compopt` 在旧 Bash 上可能不存在 :param script_text: Click 原始 Bash completion 脚本文本 :return: 兼容处理后的脚本文本 """ compatible_lines: list[str] = [] for line in script_text.splitlines(): stripped_line = line.strip() if stripped_line == 'compopt -o dirnames': indent = line[: len(line) - len(line.lstrip(' '))] compatible_lines.extend( [ f'{indent}if command -v compopt >/dev/null 2>&1; then', f'{indent} compopt -o dirnames', f'{indent}fi', ] ) continue if stripped_line == 'compopt -o default': indent = line[: len(line) - len(line.lstrip(' '))] compatible_lines.extend( [ f'{indent}if command -v compopt >/dev/null 2>&1; then', f'{indent} compopt -o default', f'{indent}fi', ] ) continue if stripped_line.startswith('complete -o nosort -F '): compatibility_line = stripped_line.replace('complete -o nosort -F ', '', 1) function_name, command_name = compatibility_line.split(' ', 1) compatible_lines.extend( [ f'if complete -o nosort -F {function_name} {command_name} 2>/dev/null; then', ' :', 'else', f' complete -F {function_name} {command_name}', 'fi', ] ) continue compatible_lines.append(line) return '\n'.join(compatible_lines) + '\n' @staticmethod def build_posix_source_command(target_file: Path) -> str: """ 构建 POSIX shell 使用的 source 命令。 :param target_file: completion 脚本文件路径 :return: source 命令 """ return f'source {target_file}' @staticmethod def build_fish_source_command(target_file: Path) -> str: """ 构建 Fish shell 使用的 source 命令。 :param target_file: completion 脚本文件路径 :return: source 命令 """ return f'status --is-interactive; and source {target_file}' @staticmethod def build_powershell_source_command(target_file: Path) -> str: """ 构建 PowerShell 使用的 source 命令。 :param target_file: completion 脚本文件路径 :return: source 命令 """ return f'. "{target_file}"' @dataclass(frozen=True) class CompletionShellRuntimePolicy: """ completion shell 运行时策略定义。 :param name: shell 名称 :param click_completion_class: Click completion 生成器类型 :param script_transformer: 脚本文本后处理函数 :param source_command_builder: 激活命令构建函数 """ name: str click_completion_class: type[Any] script_transformer: Any source_command_builder: Any @dataclass(frozen=True) class CompletionShellRuntimePolicyRegistry: """ completion shell 运行时策略注册表。 :param policies: 按 shell 名称索引的运行时策略映射 """ policies: dict[str, CompletionShellRuntimePolicy] def get(self, shell_name: str) -> CompletionShellRuntimePolicy | None: """ 获取指定 shell 的运行时策略。 :param shell_name: shell 名称 :return: 运行时策略,不存在时返回 None """ return self.policies.get(shell_name) DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES = CompletionShellRuntimePolicyRegistry( policies={ 'bash': CompletionShellRuntimePolicy( name='bash', click_completion_class=BashComplete, script_transformer=CompletionInstallerShellSupport.make_bash_completion_script_compatible, source_command_builder=CompletionInstallerShellSupport.build_posix_source_command, ), 'zsh': CompletionShellRuntimePolicy( name='zsh', click_completion_class=ZshComplete, script_transformer=CompletionInstallerShellSupport.keep_script_text, source_command_builder=CompletionInstallerShellSupport.build_posix_source_command, ), 'fish': CompletionShellRuntimePolicy( name='fish', click_completion_class=FishComplete, script_transformer=CompletionInstallerShellSupport.keep_script_text, source_command_builder=CompletionInstallerShellSupport.build_fish_source_command, ), 'powershell': CompletionShellRuntimePolicy( name='powershell', click_completion_class=PowerShellComplete, script_transformer=CompletionInstallerShellSupport.keep_script_text, source_command_builder=CompletionInstallerShellSupport.build_powershell_source_command, ), } ) class CompletionInstallerService: """ completion 安装与脚本生成服务。 该服务负责 shell 元数据解析、脚本生成、目标路径解析、激活命令构建、 rc 文件写入以及安装结果收口。 :param completion_provider_gateway: completion 提供器对外网关 :param shell_spec_registry: shell 元数据注册表 :param shell_runtime_policy_registry: shell 运行时策略注册表 """ def __init__( self, *, completion_provider_gateway: CompletionProviderGateway | None = None, shell_spec_registry: CompletionShellSpecRegistry | None = None, shell_runtime_policy_registry: CompletionShellRuntimePolicyRegistry | None = None, ) -> None: """ 初始化 completion 安装服务。 :param completion_provider_gateway: completion 提供器对外网关 :param shell_spec_registry: shell 元数据注册表 :param shell_runtime_policy_registry: shell 运行时策略注册表 :return: None """ self.completion_provider_gateway = completion_provider_gateway or COMPLETION_PROVIDER_GATEWAY self.shell_spec_registry = shell_spec_registry or COMPLETION_SHELL_SPEC_REGISTRY self.shell_runtime_policy_registry = shell_runtime_policy_registry or DEFAULT_COMPLETION_SHELL_RUNTIME_POLICIES ensure_custom_completion_classes_registered() def resolve_completion_shell_spec(self, shell: str) -> CompletionShellSpec: """ 获取指定 shell 的 completion 元数据。 :param shell: shell 名称 :return: shell 元数据 :raises typer.BadParameter: shell 不存在时抛出异常 """ normalized_shell = shell.strip().lower() shell_spec = self.shell_spec_registry.get_spec(normalized_shell) if shell_spec is None: supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells()) raise typer.BadParameter(f'不支持的 shell:{shell},可选值为 {supported_shells}') return shell_spec def resolve_shell_runtime_policy(self, shell: str) -> CompletionShellRuntimePolicy: """ 获取指定 shell 的运行时策略。 :param shell: shell 名称 :return: shell 运行时策略 :raises typer.BadParameter: 当前 shell 未实现运行时策略时抛出异常 """ shell_spec = self.resolve_completion_shell_spec(shell) runtime_policy = self.shell_runtime_policy_registry.get(shell_spec.name) if runtime_policy is None: raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现') return runtime_policy @staticmethod def build_completion_click_command(root_cli: typer.Typer) -> click.Command: """ 将 Typer 根应用转换为 Click 命令对象,供 shell completion 生成使用。 :param root_cli: Typer 根应用 :return: Click 命令对象 """ return typer.main.get_command(root_cli) def render_completion_script(self, root_cli: typer.Typer, shell: str) -> str: """ 生成指定 shell 的 completion 脚本文本。 :param root_cli: Typer 根应用 :param shell: shell 名称 :return: completion 脚本文本 :raises typer.BadParameter: 当前 shell 未实现脚本生成时抛出异常 """ shell_spec = self.resolve_completion_shell_spec(shell) if not shell_spec.supported or shell_spec.generator not in {'click', 'custom'}: raise typer.BadParameter(f'{shell_spec.name} completion 当前版本未实现') runtime_policy = self.resolve_shell_runtime_policy(shell_spec.name) click_command = self.build_completion_click_command(root_cli) stderr_buffer = io.StringIO() with redirect_stderr(stderr_buffer): script_text = runtime_policy.click_completion_class( click_command, {}, 'shierfeng', CLICK_COMPLETE_ENV_VAR, ).source() return runtime_policy.script_transformer(script_text) def resolve_completion_target(self, shell: str, target_file: Path | None = None) -> Path: """ 解析 completion 脚本目标文件路径。 :param shell: shell 名称 :param target_file: 用户显式指定的目标文件 :return: 目标文件绝对路径 """ if target_file is not None: return target_file.expanduser().resolve() shell_spec = self.resolve_completion_shell_spec(shell) return (Path.home() / shell_spec.default_target).expanduser().resolve() def resolve_completion_rc_file(self, shell: str, rc_file: Path | None = None) -> Path | None: """ 解析 completion 激活所使用的 rc 文件路径。 :param shell: shell 名称 :param rc_file: 用户显式指定的 rc 文件 :return: rc 文件绝对路径,若当前 shell 无 rc 文件则返回 None """ if rc_file is not None: return rc_file.expanduser().resolve() shell_spec = self.resolve_completion_shell_spec(shell) if shell_spec.default_rc_file is None: return None return (Path.home() / shell_spec.default_rc_file).expanduser().resolve() def build_source_command(self, target_file: Path, shell: str) -> str: """ 构建当前 shell 对应的激活命令。 :param target_file: completion 脚本文件路径 :param shell: shell 名称 :return: 激活命令文本 """ runtime_policy = self.resolve_shell_runtime_policy(shell) return runtime_policy.source_command_builder(target_file) @staticmethod def detect_active_shell() -> str: """ 检测当前进程环境下的活跃 shell 名称。 :return: shell 名称,未知时返回空字符串 """ shell_path = os.environ.get('SHELL', '').strip() if not shell_path: return '' return Path(shell_path).name.lower() def resolve_install_shell(self, shell: str | None) -> str: """ 解析安装命令实际使用的 shell。 :param shell: 用户显式指定的 shell,允许为空 :return: 实际使用的 shell 名称 :raises typer.BadParameter: 无法推断或不支持时抛出异常 """ if shell and shell.strip(): return self.resolve_completion_shell_spec(shell).name active_shell = self.detect_active_shell() if not active_shell: raise typer.BadParameter('未检测到当前 shell,请显式传入 --shell') shell_spec = self.shell_spec_registry.get_spec(active_shell) if shell_spec is None: supported_shells = ', '.join(self.completion_provider_gateway.list_completion_shells()) raise typer.BadParameter( f'当前 shell `{active_shell}` 不在支持列表中,请显式传入 --shell,可选值为 {supported_shells}' ) return shell_spec.name @staticmethod def append_activation_line(rc_file: Path, source_command: str) -> bool: """ 将激活命令追加到 rc 文件,若已存在则不重复写入。 :param rc_file: rc 文件路径 :param source_command: 激活命令文本 :return: 本次是否发生写入 """ existing_text = '' if rc_file.exists(): existing_text = rc_file.read_text(encoding='utf-8') if source_command in existing_text: return False rc_file.parent.mkdir(parents=True, exist_ok=True) with rc_file.open('a', encoding='utf-8') as file_object: if existing_text and not existing_text.endswith('\n'): file_object.write('\n') file_object.write(f'{source_command}\n') return True def install_completion_script( self, root_cli: typer.Typer, shell: str | None, *, target_file: Path | None = None, activate: bool = False, rc_file: Path | None = None, force: bool = False, ) -> dict[str, Any]: """ 安装指定 shell 的 completion 脚本文件。 :param root_cli: Typer 根应用 :param shell: shell 名称 :param target_file: completion 脚本目标文件 :param activate: 是否写入 rc 文件激活命令 :param rc_file: 自定义 rc 文件路径 :param force: 目标文件已存在时是否强制覆盖 :return: 安装结果字典 """ resolved_shell = self.resolve_install_shell(shell) shell_spec = self.resolve_completion_shell_spec(resolved_shell) if not shell_spec.supported: return { 'ok': False, 'message': f'{shell_spec.name} completion 当前版本未实现', 'shell': shell_spec.name, 'exit_code': ARGUMENT_ERROR, } resolved_target_file = self.resolve_completion_target(shell_spec.name, target_file) script_text = self.render_completion_script(root_cli, shell_spec.name) existing_text = resolved_target_file.read_text(encoding='utf-8') if resolved_target_file.exists() else None if existing_text is not None and existing_text != script_text and not force: return { 'ok': False, 'message': '目标文件已存在且内容不同,请传入 --force 覆盖', 'shell': shell_spec.name, 'targetFile': str(resolved_target_file), 'exit_code': RUNTIME_ERROR, } resolved_target_file.parent.mkdir(parents=True, exist_ok=True) resolved_target_file.write_text(script_text, encoding='utf-8') source_command = self.build_source_command(resolved_target_file, shell_spec.name) activated = shell_spec.auto_discovery rc_file_path = self.resolve_completion_rc_file(shell_spec.name, rc_file) rc_file_updated = False activation_required = not shell_spec.auto_discovery detected_shell = self.detect_active_shell() or None if activate and rc_file_path is not None: rc_file_updated = self.append_activation_line(rc_file_path, source_command) activated = True next_step = '当前 shell 会自动发现 completion 脚本,无需额外 source 命令' if activation_required and not activate: next_step = f'请执行 `{source_command}`,或重新运行并传入 --activate' elif activation_required and activate: next_step = '请重启当前 shell,或手动执行 rc 文件中的 source 命令使其立即生效' return { 'ok': True, 'message': 'completion 脚本已安装', 'shell': shell_spec.name, 'detectedShell': detected_shell, 'targetFile': str(resolved_target_file), 'activated': activated, 'activateRequested': activate, 'rcFile': str(rc_file_path) if rc_file_path is not None else None, 'rcFileUpdated': rc_file_updated, 'sourceCommand': source_command, 'autoDiscovery': shell_spec.auto_discovery, 'activationRequired': activation_required, 'nextStep': next_step, 'completeEnvVar': CLICK_COMPLETE_ENV_VAR, } COMPLETION_INSTALLER = CompletionInstallerService()