From 631cf353549df4fce3440139c592603853dcd22c Mon Sep 17 00:00:00 2001 From: Arvin <62139570+wnlen@users.noreply.github.com> Date: Fri, 24 Apr 2026 22:23:47 +0800 Subject: [PATCH] feat(completion): add bash and zsh completions --- install.sh | 1 + scripts/core/clashctl.sh | 5 + scripts/core/common.sh | 59 +++++- scripts/core/completion.sh | 419 +++++++++++++++++++++++++++++++++++++ uninstall.sh | 1 + 5 files changed, 484 insertions(+), 1 deletion(-) create mode 100644 scripts/core/completion.sh diff --git a/install.sh b/install.sh index 2b10ed9..c3d3067 100755 --- a/install.sh +++ b/install.sh @@ -30,6 +30,7 @@ mark_install_plan || true mark_install_port_plan || true install_clashctl_entry +install_clashctl_completion install_shell_alias_entry install_runtime_entry install_local_dashboard_assets diff --git a/scripts/core/clashctl.sh b/scripts/core/clashctl.sh index 6f8a531..f54c967 100644 --- a/scripts/core/clashctl.sh +++ b/scripts/core/clashctl.sh @@ -7,6 +7,7 @@ PROJECT_DIR="$(cd "$(dirname "$SCRIPT_PATH")/../.." && pwd)" source "$PROJECT_DIR/scripts/core/common.sh" source "$PROJECT_DIR/scripts/core/runtime.sh" source "$PROJECT_DIR/scripts/core/config.sh" +source "$PROJECT_DIR/scripts/core/completion.sh" source "$PROJECT_DIR/scripts/core/proxy.sh" source "$PROJECT_DIR/scripts/core/update.sh" source "$PROJECT_DIR/scripts/init/systemd.sh" @@ -42,6 +43,7 @@ Usage: status 🔍️ 查看状态总览 boot 🚦 管理开机代理接管 log/logs 📜 查看日志 + completion 💡 导出 Bash / Zsh 补全脚本 💡 更多高级能力:clashctl help advanced EOF @@ -71,6 +73,7 @@ usage_advanced() { boot proxy on|off|status 📜 仅管理开机代理保持 upgrade 🚀 升级当前或指定内核 update 🔄 更新项目代码 + completion bash|zsh 💡 导出 Shell 补全脚本 dev reset 🧪 恢复到安装前状态(保留项目目录和已下载文件) 📌 Advanced Examples: @@ -2976,6 +2979,7 @@ cmd_ui_help_summary() { printf ' %-18s %s\n' "clashctl sub" "🧩 订阅高级管理(启用 / 禁用 / 重命名 / 删除)" printf ' %-18s %s\n' "clashctl upgrade" "🚀 升级当前或指定内核" printf ' %-18s %s\n' "clashctl update" "🔄 更新项目代码" + printf ' %-18s %s\n' "clashctl completion" "💡 导出 Bash / Zsh 补全脚本" echo "📜 日志" printf ' %-18s %s\n' "clashctl doctor" "🩺 诊断面板" printf ' %-18s %s\n' "clashctl log/logs" "📜 查看日志" @@ -7275,6 +7279,7 @@ case "$cmd" in proxy) cmd_proxy "$@" ;; upgrade) cmd_upgrade "$@" ;; update) cmd_update "$@" ;; + completion) cmd_completion "$@" ;; start-direct) cmd_start_direct "$@" ;; stop-direct) cmd_stop_direct "$@" ;; restart-direct) cmd_restart_direct "$@" ;; diff --git a/scripts/core/common.sh b/scripts/core/common.sh index df0a7e7..a3cc889 100644 --- a/scripts/core/common.sh +++ b/scripts/core/common.sh @@ -2307,6 +2307,30 @@ alias_source_file() { echo "$PROJECT_DIR/scripts/core/alias.sh" } +completion_dir() { + if [ "$INSTALL_SCOPE" = "system" ]; then + echo "/etc/profile.d" + else + echo "$HOME/.config/clash-for-linux" + fi +} + +bash_completion_entry_file() { + if [ "$INSTALL_SCOPE" = "system" ]; then + echo "$(completion_dir)/clash-for-linux-completion.bash" + else + echo "$(completion_dir)/completion.bash" + fi +} + +zsh_completion_entry_file() { + if [ "$INSTALL_SCOPE" = "system" ]; then + echo "$(completion_dir)/clash-for-linux-completion.zsh" + else + echo "$(completion_dir)/completion.zsh" + fi +} + clashctl_entry_target() { echo "$(command_install_dir)/clashctl" } @@ -2411,12 +2435,14 @@ cleanup_legacy_shell_entries() { } install_shell_alias_entry() { - local profile_file alias_file shell_rc + local profile_file alias_file shell_rc bash_completion_file zsh_completion_file cleanup_legacy_shell_entries profile_file="$(profile_entry_file)" alias_file="$(alias_source_file)" + bash_completion_file="$(bash_completion_entry_file)" + zsh_completion_file="$(zsh_completion_entry_file)" mkdir -p "$(dirname "$profile_file")" [ -f "$alias_file" ] || die "未找到 alias 脚本:$alias_file" @@ -2430,6 +2456,18 @@ if [ -n "\${BASH_VERSION:-}" ] && [ -z "\${CLASH_FOR_LINUX_SHELL_LOADED:-}" ]; t export CLASH_FOR_LINUX_SHELL_LOADED="1" source "$alias_file" fi + +case "\$-" in + *i*) + if [ -n "\${BASH_VERSION:-}" ] && [ -z "\${CLASH_FOR_LINUX_BASH_COMPLETION_LOADED:-}" ] && [ -f "$bash_completion_file" ]; then + export CLASH_FOR_LINUX_BASH_COMPLETION_LOADED="1" + source "$bash_completion_file" + elif [ -n "\${ZSH_VERSION:-}" ] && [ -z "\${CLASH_FOR_LINUX_ZSH_COMPLETION_LOADED:-}" ] && [ -f "$zsh_completion_file" ]; then + export CLASH_FOR_LINUX_ZSH_COMPLETION_LOADED="1" + source "$zsh_completion_file" + fi + ;; +esac EOF chmod +x "$profile_file" @@ -2440,6 +2478,21 @@ EOF install_alias_command_wrappers } +install_clashctl_completion() { + local bash_target zsh_target + + bash_target="$(bash_completion_entry_file)" + zsh_target="$(zsh_completion_entry_file)" + + mkdir -p "$(dirname "$bash_target")" + mkdir -p "$(dirname "$zsh_target")" + + bash "$PROJECT_DIR/scripts/core/clashctl.sh" completion bash > "$bash_target" + bash "$PROJECT_DIR/scripts/core/clashctl.sh" completion zsh > "$zsh_target" + + chmod +x "$bash_target" "$zsh_target" 2>/dev/null || true +} + remove_clashctl_entry() { rm -f "$(clashctl_entry_target)" "$(clashctl_bin_entry_target)" 2>/dev/null || true remove_alias_command_wrappers @@ -2461,6 +2514,10 @@ remove_shell_alias_entry() { fi } +remove_clashctl_completion() { + rm -f "$(bash_completion_entry_file)" "$(zsh_completion_entry_file)" 2>/dev/null || true +} + install_has_subscription() { [ -n "$(subscription_url 2>/dev/null || true)" ] } diff --git a/scripts/core/completion.sh b/scripts/core/completion.sh new file mode 100644 index 0000000..c64dbee --- /dev/null +++ b/scripts/core/completion.sh @@ -0,0 +1,419 @@ +#!/usr/bin/env bash + +completion_emit_script_body() { + printf '_clash_for_linux_project_dir=%q\n' "$PROJECT_DIR" + cat <<'EOF' +_clash_for_linux_runtime_dir="${_clash_for_linux_project_dir}/runtime" +_clash_for_linux_subscription_file="${_clash_for_linux_runtime_dir}/subscriptions.yaml" +_clash_for_linux_mixin_file="${_clash_for_linux_runtime_dir}/mixin.yaml" +_clash_for_linux_local_subscription_dir="${_clash_for_linux_runtime_dir}/subscriptions" +_clash_for_linux_yq_bin="${_clash_for_linux_runtime_dir}/bin/yq" + +# Hard constraints for completion: +# - local-only and offline +# - no controller or network access +# - low-latency best effort with immediate silent fallback +# - skip YAML-based dynamic completion when yq is missing or fails + +_clash_for_linux_add_matches() { + local cur="$1" + local candidate + shift + + for candidate in "$@"; do + [ -n "${candidate:-}" ] || continue + if [ -z "$cur" ] || [ "${candidate:0:${#cur}}" = "$cur" ]; then + COMPREPLY+=("$candidate") + fi + done +} + +_clash_for_linux_add_stream_matches() { + local cur="$1" + local candidate + + while IFS= read -r candidate; do + [ -n "${candidate:-}" ] || continue + [ "$candidate" = "null" ] && continue + if [ -z "$cur" ] || [ "${candidate:0:${#cur}}" = "$cur" ]; then + COMPREPLY+=("$candidate") + fi + done +} + +_clash_for_linux_add_subscription_matches() { + local cur="$1" + + [ -x "$_clash_for_linux_yq_bin" ] || return 0 + [ -s "$_clash_for_linux_subscription_file" ] || return 0 + + _clash_for_linux_add_stream_matches "$cur" < <( + "$_clash_for_linux_yq_bin" eval '.sources | keys | .[]' "$_clash_for_linux_subscription_file" 2>/dev/null + ) +} + +_clash_for_linux_add_relay_matches() { + local cur="$1" + + [ -x "$_clash_for_linux_yq_bin" ] || return 0 + [ -s "$_clash_for_linux_mixin_file" ] || return 0 + + _clash_for_linux_add_stream_matches "$cur" < <( + "$_clash_for_linux_yq_bin" eval '(.append["proxy-groups"] // [])[] | select(.type == "relay") | .name' "$_clash_for_linux_mixin_file" 2>/dev/null + ) +} + +_clash_for_linux_add_local_subscription_matches() { + local cur="$1" + local path + + [ -d "$_clash_for_linux_local_subscription_dir" ] || return 0 + + while IFS= read -r path; do + [ -n "${path:-}" ] || continue + if [ -z "$cur" ] || [ "${path:0:${#cur}}" = "$cur" ]; then + COMPREPLY+=("$path") + fi + done < <( + for path in "$_clash_for_linux_local_subscription_dir"/*; do + [ -f "$path" ] || continue + printf '%s\n' "${path##*/}" + done 2>/dev/null + ) +} + +_clash_for_linux_complete_add() { + local cur="$1" + local rel_index="$2" + local arg1="${3:-}" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" local + return 0 + fi + + if [ "$arg1" = "local" ]; then + _clash_for_linux_add_local_subscription_matches "$cur" + fi +} + +_clash_for_linux_complete_use() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" --recommend -r --verbose -v + + case "$cur" in + -*) return 0 ;; + esac + + _clash_for_linux_add_subscription_matches "$cur" +} + +_clash_for_linux_complete_health() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" --verbose -v + + case "$cur" in + -*) return 0 ;; + esac + + _clash_for_linux_add_subscription_matches "$cur" +} + +_clash_for_linux_complete_status() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" --verbose -v +} + +_clash_for_linux_complete_boot() { + local cur="$1" + local rel_index="$2" + local arg1="${3:-}" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" on off status runtime proxy help -h --help + return 0 + fi + + case "$arg1" in + runtime|proxy) + if [ "$rel_index" -eq 2 ]; then + _clash_for_linux_add_matches "$cur" on off status + fi + ;; + esac +} + +_clash_for_linux_complete_config() { + local cur="$1" + local rel_index="$2" + local arg1="${3:-}" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" show explain regen kernel + return 0 + fi + + if [ "$arg1" = "kernel" ] && [ "$rel_index" -eq 2 ]; then + _clash_for_linux_add_matches "$cur" mihomo clash + fi +} + +_clash_for_linux_complete_mixin() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" edit raw runtime help -e -c -r --edit --raw --runtime -h --help + fi +} + +_clash_for_linux_complete_relay() { + local cur="$1" + local rel_index="$2" + local arg1="${3:-}" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" add list ls remove rm delete help -h --help + return 0 + fi + + case "$arg1" in + remove|rm|delete) + if [ "$rel_index" -eq 2 ]; then + _clash_for_linux_add_relay_matches "$cur" + fi + ;; + add) + _clash_for_linux_add_matches "$cur" --domain --match + ;; + esac +} + +_clash_for_linux_complete_sub() { + local cur="$1" + local rel_index="$2" + local arg1="${3:-}" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" list use set enable disable rename remove rm del health help -h --help + return 0 + fi + + case "$arg1" in + use|enable|disable|remove|rm|del) + if [ "$rel_index" -eq 2 ]; then + _clash_for_linux_add_subscription_matches "$cur" + fi + ;; + rename) + if [ "$rel_index" -eq 2 ]; then + _clash_for_linux_add_subscription_matches "$cur" + fi + ;; + health) + _clash_for_linux_add_matches "$cur" --verbose -v + if [ "$rel_index" -eq 2 ]; then + case "$cur" in + -*) return 0 ;; + esac + _clash_for_linux_add_subscription_matches "$cur" + fi + ;; + esac +} + +_clash_for_linux_complete_tun() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" status on off doctor + fi +} + +_clash_for_linux_complete_upgrade() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" mihomo clash -v --verbose +} + +_clash_for_linux_complete_update() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" --force --regenerate +} + +_clash_for_linux_complete_dev() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" reset + fi +} + +_clash_for_linux_complete_completion() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" bash zsh + fi +} + +_clash_for_linux_complete_help() { + local cur="$1" + local rel_index="$2" + + COMPREPLY=() + + if [ "$rel_index" -eq 1 ]; then + _clash_for_linux_add_matches "$cur" advanced + fi +} + +_clash_for_linux_complete_top_level() { + local cur="$1" + + COMPREPLY=() + _clash_for_linux_add_matches "$cur" \ + add use ls health select on off status status-next \ + boot log logs doctor ui secret tun dev config mixin \ + relay profile sub proxy upgrade update completion help \ + -h --help +} + +_clash_for_linux_complete_command() { + local root cur canonical rel_index + local arg1="" arg2="" arg3="" + + root="${COMP_WORDS[0]##*/}" + cur="${COMP_WORDS[COMP_CWORD]:-}" + + case "$root" in + clashctl) + if [ "$COMP_CWORD" -eq 1 ]; then + _clash_for_linux_complete_top_level "$cur" + return 0 + fi + canonical="${COMP_WORDS[1]:-}" + rel_index=$((COMP_CWORD - 1)) + arg1="${COMP_WORDS[2]:-}" + arg2="${COMP_WORDS[3]:-}" + arg3="${COMP_WORDS[4]:-}" + ;; + clashrelay) + canonical="relay" + rel_index=$COMP_CWORD + arg1="${COMP_WORDS[1]:-}" + arg2="${COMP_WORDS[2]:-}" + arg3="${COMP_WORDS[3]:-}" + ;; + clashmixin) + canonical="mixin" + rel_index=$COMP_CWORD + arg1="${COMP_WORDS[1]:-}" + ;; + clashsecret) + canonical="secret" + rel_index=$COMP_CWORD + arg1="${COMP_WORDS[1]:-}" + ;; + clashupgrade) + canonical="upgrade" + rel_index=$COMP_CWORD + arg1="${COMP_WORDS[1]:-}" + ;; + clashtun) + canonical="tun" + rel_index=$COMP_CWORD + arg1="${COMP_WORDS[1]:-}" + ;; + *) + COMPREPLY=() + return 0 + ;; + esac + + case "$canonical" in + add) _clash_for_linux_complete_add "$cur" "$rel_index" "$arg1" ;; + use) _clash_for_linux_complete_use "$cur" ;; + health) _clash_for_linux_complete_health "$cur" ;; + status) _clash_for_linux_complete_status "$cur" ;; + boot) _clash_for_linux_complete_boot "$cur" "$rel_index" "$arg1" ;; + config) _clash_for_linux_complete_config "$cur" "$rel_index" "$arg1" ;; + mixin) _clash_for_linux_complete_mixin "$cur" "$rel_index" ;; + relay) _clash_for_linux_complete_relay "$cur" "$rel_index" "$arg1" ;; + sub) _clash_for_linux_complete_sub "$cur" "$rel_index" "$arg1" ;; + tun) _clash_for_linux_complete_tun "$cur" "$rel_index" ;; + upgrade) _clash_for_linux_complete_upgrade "$cur" ;; + update) _clash_for_linux_complete_update "$cur" ;; + dev) _clash_for_linux_complete_dev "$cur" "$rel_index" ;; + completion) _clash_for_linux_complete_completion "$cur" "$rel_index" ;; + help) _clash_for_linux_complete_help "$cur" "$rel_index" ;; + *) + COMPREPLY=() + ;; + esac +} + +complete -F _clash_for_linux_complete_command clashctl +complete -F _clash_for_linux_complete_command clashrelay +complete -F _clash_for_linux_complete_command clashmixin +complete -F _clash_for_linux_complete_command clashsecret +complete -F _clash_for_linux_complete_command clashupgrade +complete -F _clash_for_linux_complete_command clashtun +EOF +} + +completion_emit_bash_script() { + completion_emit_script_body +} + +completion_emit_zsh_script() { + cat <<'EOF' +autoload -Uz bashcompinit 2>/dev/null || return 0 +bashcompinit >/dev/null 2>&1 || return 0 +EOF + completion_emit_script_body +} + +cmd_completion() { + case "${1:-}" in + bash) + completion_emit_bash_script + ;; + zsh) + completion_emit_zsh_script + ;; + *) + die_usage "completion 参数不合法" "clashctl completion bash|zsh" + ;; + esac +} diff --git a/uninstall.sh b/uninstall.sh index 3caa3d3..2898f7b 100755 --- a/uninstall.sh +++ b/uninstall.sh @@ -41,6 +41,7 @@ detect_install_scope auto service_stop || true remove_runtime_entry || true remove_clashctl_entry || true +remove_clashctl_completion || true remove_shell_alias_entry || true clear_shell_proxy_persist_state || true