本文档为标准落地配置方案,旨在供开发者或 AI Agent 直接读取并一键执行配置。配置完成后,将具备:
- 眼科级抗疲劳体验:微温浅抹茶豆沙绿护眼底色 + 墨迹黑字 + 焦糖琥珀色思考字,彻底告别反光与视疲劳;
- 1 步直达战场:敲
cx自动免密登录、自动进工作区、自动挂入后台保活会话,配置与代码解耦,换服务器零成本; - 跨机器剪贴板与文件瞬移:Mac 截图在服务器敲
pi或 Yazi 敲P秒级落地;本地文件拖入终端直接拉取上传; - 全能终端管理:Yazi 终端看图自动铺满并支持自由缩放、Glow 2.0 原生 Markdown 彩色宽幅排版、路径双击
c c秒拷回 Mac; - 跨 Worktree 智能文件直达 (
wto):解决 Claude Code 在独立 worktree 中改动代码后路径不匹配痛点,fzf 上下键直接挑选,回车秒开 Yazi 定位高亮。
架构拓扑
+-------------------------------------------------------------+
| Mac 本地工作站 (macOS) |
| |
| 1. Ghostty 终端 (MesloLGL Nerd Font + 中值护眼豆沙绿) |
| 2. 本地桥接服务: ~/.config/cx/mac_bridge.py (监听 127.0.0.1:19222)|
| 3. SSH 客户端: ~/.ssh/config (RemoteForward 19222) |
| 4. Shell 极简引入: ~/.zshrc -> ~/.config/cx/ (工具/配置/桥接一体)|
+------------------------------+------------------------------+
| SSH (反向隧道 19222)
v
+-------------------------------------------------------------+
| 远程 Linux 服务器 (Ubuntu / Debian / CentOS) |
| |
| 1. Tmux 3.5a: 开启 allow-passthrough (Kitty 图形穿透) |
| 2. 交互工具: pi (剪贴板图), up (拖拽上传), cb (拷回 Mac), |
| wto (跨 Worktree 智能检索直通 Yazi) |
| 3. Yazi (v26.9+): zoom 缩放、piper + glow 2.0、copy-mac |
+-------------------------------------------------------------+
参数占位符约定:
-<SERVER_HOST>:服务器 SSH 别名(示例:dev-server)
-<SERVER_IP>:服务器真实 IP(示例:10.0.0.10)
-<REMOTE_USER>:远程服务器登录用户名(示例:developer)
-<PROJECT_NAME>:默认主项目目录名(示例:my_project)
第一部分:Mac 本地配置(AI 自动执行指南)
1.1 安装必要 CLI 工具
通过 Homebrew 安装剪贴板提取器及终端工具:
brew install pngpaste glow lazygit
1.2 配置 Ghostty 终端
写入或覆盖 ~/.config/ghostty/config:
# ==============================================================================
# Ghostty 配置 - 眼科防疲劳浅豆沙绿定制版
# ==============================================================================
# 1. 主题与色彩(经典草木豆沙绿中值色 #c1d9c3,明度 80%,防反光不刺眼)
background = #c1d9c3
foreground = #1c2c21
cursor-color = #286349
cursor-text = #c1d9c3
selection-background = #a2cca6
selection-foreground = #1c2c21
# ANSI 16 色调色板(墨水纸感:黄色调为深焦糖色 #7a3e00,确保 Claude 思考文字凝实清晰)
palette = 0=#1c2c21
palette = 1=#ba3838
palette = 2=#2a752e
palette = 3=#7a3e00
palette = 4=#175983
palette = 5=#762c8e
palette = 6=#10766d
palette = 7=#cedecf
palette = 8=#44584a
palette = 9=#cf2828
palette = 10=#328537
palette = 11=#7a3e00
palette = 12=#176ebd
palette = 13=#871fa2
palette = 14=#008273
palette = 15=#f0f7f0
# 智能对比度防护(确保状态栏小字和辅助灰色绝对清晰)
minimum-contrast = 1.15
# 2. 窗口外观(浅色模式设为 1.0 纯色不透,杜绝桌面杂色重影干扰)
background-opacity = 1.0
background-blur = false
window-padding-x = 14
window-padding-y = 12
window-padding-balance = true
macos-titlebar-style = transparent
# 3. 字体配置
font-family = MesloLGL Nerd Font
font-size = 14.5
# 4. 光标与按键
cursor-style = block
cursor-style-blink = false
macos-option-as-alt = true
# 5. 滚动与控速(驯服触控板暴冲)
scrollback-limit = 100000
mouse-scroll-multiplier = precision:1,discrete:1
# 6. 剪贴板交互
clipboard-read = allow
clipboard-write = allow
copy-on-select = clipboard
# 7. 终端环境
term = xterm-256color
# 8. 分屏快捷键
keybind = super+d=new_split:right
keybind = super+shift+d=new_split:down
keybind = super+[=goto_split:previous
keybind = super+]=goto_split:next
keybind = super+shift+enter=toggle_split_zoom
keybind = super+ctrl+==equalize_splits
keybind = super+enter=unbind
1.3 部署 Mac 本地跨端桥接服务
创建本地桥接脚本 ~/.config/cx/mac_bridge.py:
#!/usr/bin/env python3
import http.server
import subprocess
import os
import urllib.parse
import sys
PORT = 19222
def detect_clipboard():
# 1. 尝试检测 Finder 复制的文件 (Cmd+C)
script = """
try
set singleItem to (the clipboard as «class furl»)
return POSIX path of singleItem
on error
try
set theList to (the clipboard as list)
set res to {}
repeat with itm in theList
try
set end of res to POSIX path of itm
end try
end repeat
set AppleScript's text item delimiters to linefeed
return res as text
on error
return ""
end try
end try
"""
try:
proc = subprocess.run(['osascript', '-e', script], capture_output=True, text=True, timeout=2)
out = proc.stdout.strip()
if out:
lines = [l.strip() for l in out.splitlines() if l.strip()]
valid = [p for p in lines if os.path.exists(p)]
if valid:
return {
"type": "files",
"files": [{"path": p, "name": os.path.basename(p), "size": os.path.getsize(p)} for p in valid]
}
except Exception:
pass
# 2. 尝试检测剪贴板原生图片 (截屏数据)
try:
png_check = subprocess.run(['/opt/homebrew/bin/pngpaste', '-'], stdout=subprocess.PIPE, stderr=subprocess.PIPE, timeout=1)
if png_check.returncode == 0 and len(png_check.stdout) > 0:
return {"type": "image", "size": len(png_check.stdout)}
except Exception:
pass
# 3. 尝试检测 pbpaste 文本内容是否指向有效本地文件
try:
proc = subprocess.run(['pbpaste'], capture_output=True, text=True, timeout=1)
text = proc.stdout.strip()
if text.startswith('/') and '\n' not in text and os.path.exists(text):
return {
"type": "files",
"files": [{"path": text, "name": os.path.basename(text), "size": os.path.getsize(text)}]
}
elif text:
return {"type": "text", "preview": text[:60]}
except Exception:
pass
return {"type": "empty"}
class BridgeHandler(http.server.BaseHTTPRequestHandler):
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
path = parsed.path
if path == '/ping':
self.send_response(200)
self.end_headers()
self.wfile.write(b'pong')
return
elif path == '/clipboard-status':
status = detect_clipboard()
body = json.dumps(status, ensure_ascii=False).encode('utf-8')
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.send_header('Content-Length', str(len(body)))
self.end_headers()
self.wfile.write(body)
return
elif path == '/clipboard-image':
try:
proc = subprocess.run(['/opt/homebrew/bin/pngpaste', '-'], stdout=subprocess.PIPE, stderr=subprocess.PIPE)
if proc.returncode == 0 and len(proc.stdout) > 0:
self.send_response(200)
self.send_header('Content-Type', 'image/png')
self.send_header('Content-Length', str(len(proc.stdout)))
self.end_headers()
self.wfile.write(proc.stdout)
else:
self.send_response(404)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(b'Clipboard does not contain an image')
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(str(e).encode())
elif path == '/fetch-file':
query = urllib.parse.parse_qs(parsed.query)
filepath = query.get('path', [''])[0]
filepath = os.path.expanduser(filepath)
if not os.path.exists(filepath):
self.send_response(404)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(f'File not found: {filepath}'.encode())
return
try:
f = open(filepath, 'rb')
size = os.path.getsize(filepath)
self.send_response(200)
self.send_header('Content-Type', 'application/octet-stream')
self.send_header('Content-Length', str(size))
self.end_headers()
with f:
while chunk := f.read(65536):
self.wfile.write(chunk)
except PermissionError as e:
self.send_response(403)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(f'Permission denied on Mac: {e}'.encode())
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(str(e).encode())
elif path == '/open-remote-vscode':
query = urllib.parse.parse_qs(parsed.query)
remote_path = query.get('path', [''])[0]
host = query.get('host', ['<SERVER_HOST>'])[0]
if remote_path:
try:
url = f"vscode://vscode-remote/ssh-remote+{host}{remote_path}"
subprocess.Popen(['open', url])
self.send_response(200)
self.end_headers()
self.wfile.write(b'ok')
return
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
return
else:
self.send_response(400)
self.end_headers()
return
elif path == '/open-browser':
query = urllib.parse.parse_qs(parsed.query)
target_url = query.get('url', [''])[0]
if target_url and (target_url.startswith('http://') or target_url.startswith('https://')):
try:
subprocess.Popen(['open', target_url])
self.send_response(200)
self.end_headers()
self.wfile.write(b'ok')
return
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
return
else:
self.send_response(400)
self.end_headers()
self.wfile.write(b'invalid url')
return
elif path == '/preview':
preview_file = '/tmp/wto_preview.html'
if os.path.exists(preview_file):
try:
with open(preview_file, 'rb') as f:
content = f.read()
self.send_response(200)
self.send_header('Content-Type', 'text/html; charset=utf-8')
self.send_header('Content-Length', str(len(content)))
self.end_headers()
self.wfile.write(content)
return
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(str(e).encode())
return
else:
self.send_response(404)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(b'No preview available')
return
else:
self.send_response(404)
self.end_headers()
def do_POST(self):
parsed = urllib.parse.urlparse(self.path)
if parsed.path == '/set-clipboard':
length = int(self.headers.get('Content-Length', 0))
data = self.rfile.read(length)
try:
proc = subprocess.Popen(['pbcopy'], stdin=subprocess.PIPE)
proc.communicate(input=data)
self.send_response(200)
self.end_headers()
self.wfile.write(b'ok')
except Exception as e:
self.send_response(500)
self.end_headers()
self.wfile.write(str(e).encode())
elif parsed.path == '/preview-upload':
length = int(self.headers.get('Content-Length', 0))
data = self.rfile.read(length)
try:
preview_file = '/tmp/wto_preview.html'
with open(preview_file, 'wb') as f:
f.write(data)
preview_url = f"http://127.0.0.1:{PORT}/preview"
subprocess.Popen(['open', preview_url])
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
self.wfile.write(json.dumps({'url': preview_url}).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(str(e).encode())
elif parsed.path == '/save-file':
query = urllib.parse.parse_qs(parsed.query)
raw_filename = query.get('filename', ['downloaded_file'])[0]
clean_filename = os.path.basename(raw_filename) or 'downloaded_file'
dest_dir = os.path.expanduser('~/Downloads')
os.makedirs(dest_dir, exist_ok=True)
# 重名处理:若已存在则追加 (1), (2) 防覆盖
name, ext = os.path.splitext(clean_filename)
target_path = os.path.join(dest_dir, clean_filename)
counter = 1
while os.path.exists(target_path):
target_path = os.path.join(dest_dir, f"{name} ({counter}){ext}")
counter += 1
length = int(self.headers.get('Content-Length', 0))
remaining = length
try:
with open(target_path, 'wb') as f:
while remaining > 0:
chunk_size = min(65536, remaining)
chunk = self.rfile.read(chunk_size)
if not chunk:
break
f.write(chunk)
remaining -= len(chunk)
# 自动在 Mac Finder 中高亮选中该文件
reveal = query.get('reveal', ['1'])[0]
if reveal != '0':
subprocess.Popen(['open', '-R', target_path])
self.send_response(200)
self.send_header('Content-Type', 'application/json; charset=utf-8')
self.end_headers()
resp = {
'ok': True,
'filename': os.path.basename(target_path),
'path': target_path,
'size': os.path.getsize(target_path)
}
self.wfile.write(json.dumps(resp, ensure_ascii=False).encode('utf-8'))
except Exception as e:
self.send_response(500)
self.send_header('Content-Type', 'text/plain; charset=utf-8')
self.end_headers()
self.wfile.write(str(e).encode('utf-8'))
else:
self.send_response(404)
self.end_headers()
def log_message(self, format, *args):
pass
if __name__ == '__main__':
server = http.server.ThreadingHTTPServer(('127.0.0.1', PORT), BridgeHandler)
server.serve_forever()
赋权、创建软链并启动:
chmod +x ~/.config/cx/mac_bridge.py
ln -sf ~/.config/cx/mac_bridge.py ~/.local/bin/mac_bridge.py
pkill -f mac_bridge.py 2>/dev/null || true
nohup python3 ~/.config/cx/mac_bridge.py >/dev/null 2>&1 &
1.4 配置 SSH 客户端反向转发 (~/.ssh/config)
在 ~/.ssh/config 中针对目标服务器添加配置(请替换 <SERVER_HOST>、<SERVER_IP>、<REMOTE_USER>):
Host <SERVER_HOST>
HostName <SERVER_IP>
User <REMOTE_USER>
RequestTTY yes
RemoteForward 19222 127.0.0.1:19222
1.5 配置 Mac 本地 Shell 直达函数与解耦配置 (cx)
为了在更换服务器或换项目时彻底解耦、无需频繁修改 ~/.zshrc,我们将连接目标参数独立抽取到单独的配置文件中。
1.5.1 创建目标服务器配置文件 (~/.config/cx/config)
mkdir -p ~/.config/cx
cat << 'EOF' > ~/.config/cx/config
# ==============================================================================
# cx 远程开发环境配置 (~/.config/cx/config)
# 以后换服务器或换项目,直接修改这里的参数即可!
# ==============================================================================
# SSH 目标主机别名(对应 ~/.ssh/config 里的 Host)
CX_HOST="<SERVER_HOST>"
# 远程默认主项目名称
CX_PROJECT="<PROJECT_NAME>"
# 远程工作区主目录
CX_REMOTE_WORKSPACES="/data/azrael/workspaces"
# 远程环境入口脚本(若不存在自动降级)
CX_REMOTE_BASH="~/.local/bin/azbash"
EOF
1.5.2 创建独立工具脚本 (~/.config/cx/cx.zsh)
将快捷别名与 cx 完整函数写入独立文件,彻底避免频繁修改 ~/.zshrc:
cat << 'EOF' > ~/.config/cx/cx.zsh
# ==============================================================================
# cx 远程终端与开发工具包 (~/.config/cx/cx.zsh)
# 集中管理 cx 函数、快捷别名及本地跨机器桥接守护服务
# ==============================================================================
# 1. 自动守护本地跨机器桥接服务 (监听 127.0.0.1:19222)
_cx_bridge_script="$HOME/.config/cx/mac_bridge.py"
pgrep -f "mac_bridge.py" >/dev/null || ([ -f "$_cx_bridge_script" ] && nohup python3 "$_cx_bridge_script" >/dev/null 2>&1 & disown)
# 2. 本地常用快捷别名与函数
alias lg='lazygit'
md() {
if [ -z "$1" ]; then
glow
else
glow -p "$@"
fi
}
alias gw='glow'
# 3. cx 远程开发极速直达函数 (读取同目录 config 与 mac_bridge.py)
unalias cx 2>/dev/null || true
function cx() {
local cfg_dir="$HOME/.config/cx"
local cfg_file="$cfg_dir/config"
local bridge_script="$cfg_dir/mac_bridge.py"
# 自动保证桥接运行
pgrep -f "mac_bridge.py" >/dev/null || ([ -f "$bridge_script" ] && nohup python3 "$bridge_script" >/dev/null 2>&1 & disown)
local default_host="<SERVER_HOST>"
local default_project="<PROJECT_NAME>"
local default_workspaces="$HOME/workspaces"
local default_bash=""
[ -f "$cfg_file" ] && source "$cfg_file"
local host="${CX_HOST:-$default_host}"
local project="${CX_PROJECT:-$default_project}"
local remote_workspaces="${CX_REMOTE_WORKSPACES:-$default_workspaces}"
local remote_bash="${CX_REMOTE_BASH:-$default_bash}"
local arg="${1:-$project}"
# 帮助命令支持: -h, --help, help, h, --hlep
if [[ "$arg" == "-h" || "$arg" == "--help" || "$arg" == "help" || "$arg" == "h" || "$arg" == "--hlep" ]]; then
cat << EOF
cx - 远程开发环境极速直达工具 (目标服务器: $host)
用法:
cx 连入/恢复默认主会话 ($project)
cx new 自动新建序号递增的全新独立会话 (如 ${project}_1)
cx <序号> 连入/新建对应序号会话 (例: cx 1 -> ${project}_1)
cx <名字> 连入/新建指定名称会话 (例: cx test)
cx ls 查看当前远程所有后台保活会话 (别名: list)
cx k 弹出交互式菜单挑选关闭会话 (别名: kill)
cx k <名字> 直接关闭指定后台会话
cx ka 一键关闭全部后台保活会话 (别名: kill-all)
cx bridge 查看本地跨机器传输桥接服务运行状态
cx bridge restart 一键重启本地传输桥接服务 (监听 127.0.0.1:19222)
cx raw 普通 SSH 直连服务器 (不接管 tmux,别名: ssh)
cx config 查看/配置目标服务器参数 ($cfg_file)
cx -h, h, help 显示本帮助说明 (别名: --help)
常用场景:
• 日常开发/上班: 直接敲 cx
• 另起独立新任务: 敲 cx new 或 cx 1
• 查后台正在跑什么: 敲 cx ls
• 关掉用完的会话: 敲 cx k
• 本地桥接异常/卡死: 敲 cx bridge restart
• 更换目标服务器: 敲 cx config 修改配置
EOF
return 0
fi
# 桥接服务生命周期管理
if [[ "$arg" == "bridge" || "$arg" == "br" ]]; then
local sub="${2:-status}"
if [[ "$sub" == "restart" || "$sub" == "re" ]]; then
echo "⚡ 正在重启本地传输桥接服务..."
pkill -f "mac_bridge.py" 2>/dev/null || true
sleep 0.5
if [ -f "$bridge_script" ]; then
nohup python3 "$bridge_script" >/dev/null 2>&1 & disown
sleep 0.5
fi
elif [[ "$sub" == "stop" ]]; then
pkill -f "mac_bridge.py" 2>/dev/null && echo "✔ 本地传输桥接服务已停止" || echo "⚠️ 桥接服务未在运行"
return 0
elif [[ "$sub" == "start" ]]; then
if pgrep -f "mac_bridge.py" >/dev/null; then
echo "✔ 本地传输桥接服务已在运行中"
else
[ -f "$bridge_script" ] && nohup python3 "$bridge_script" >/dev/null 2>&1 & disown
sleep 0.5
echo "✔ 本地传输桥接服务已启动"
fi
return 0
fi
# 打印状态
local pids=$(pgrep -f "mac_bridge.py" | tr '\n' ' ')
echo "📄 桥接服务脚本: $bridge_script"
if [ -n "$pids" ]; then
local probe=$(curl -s --max-time 1 http://127.0.0.1:19222/ping 2>/dev/null)
if [ "$probe" = "pong" ]; then
echo "✔ 运行状态: 正在运行 (PID: $pids, 端口 19222 响应: $probe)"
else
echo "⚠️ 运行状态: 进程存在 (PID: $pids) 但端口 19222 未响应,可执行 cx bridge restart 重试"
fi
else
echo "✖ 运行状态: 未运行 (可执行 cx bridge restart 启动)"
fi
return 0
fi
if [[ "$arg" == "config" || "$arg" == "conf" ]]; then
echo "📄 当前 cx 配置文件路径: $cfg_file"
echo "----------------------------------------"
if [ -f "$cfg_file" ]; then
cat "$cfg_file"
else
echo "⚠️ 配置文件尚未创建,当前使用内置默认值: host=$host, project=$project"
fi
echo "----------------------------------------"
echo "💡 提示:使用编辑器直接修改该文件即可随时切换目标服务器/项目!"
return 0
fi
if [[ "$arg" == "ls" || "$arg" == "list" ]]; then
ssh -q -o ClearAllForwardings=yes -o RemoteCommand=none "$host" "bash -c 'echo \"=== 当前后台保活会话 ===\"; tmux ls 2>/dev/null || echo \"暂无运行中的会话\"; echo \"\"; echo \"💡 连接提示: cx 默认主会话 | cx new 开新会话 | cx 1/2/3 进对应序号 | cx <名字> 进指定会话 | cx -h 查看完整帮助\"'"
elif [[ "$arg" == "kill" || "$arg" == "k" ]]; then
local target="$2"
if [ -n "$target" ]; then
ssh -q -o ClearAllForwardings=yes -o RemoteCommand=none "$host" "bash -c 'tmux kill-session -t $target 2>/dev/null && echo \"✔ 已关闭会话: $target\" || echo \"✖ 未找到会话: $target\"'"
else
ssh -q -o ClearAllForwardings=yes -o RemoteCommand=none -t "$host" "bash -i -c 'tkill'"
fi
elif [[ "$arg" == "kill-all" || "$arg" == "ka" ]]; then
ssh -q -o ClearAllForwardings=yes -o RemoteCommand=none "$host" "bash -c 'tmux kill-server 2>/dev/null; echo \"✔ 已关闭全部后台保活会话\"'"
elif [[ "$arg" == "raw" || "$arg" == "ssh" ]]; then
ssh "$host"
elif [[ "$arg" == "new" || "$arg" == "add" ]]; then
ssh -o LogLevel=ERROR -o RemoteCommand=none -t "$host" "bash -c '
i=1
while tmux has-session -t \"${project}_\$i\" 2>/dev/null; do
i=\$((i+1))
done
sess=\"${project}_\$i\"
echo \"⚡ 创建全新独立会话: \$sess\"
cd \"$remote_workspaces/$project\" 2>/dev/null || cd ~
exec tmux new-session -s \"\$sess\"
'"
else
local sess="$arg"
if [[ "$arg" =~ ^[0-9]+$ ]]; then
sess="${project}_${arg}"
fi
ssh -o LogLevel=ERROR -o RemoteCommand=none -t "$host" "bash -c '
if tmux has-session -t \"$sess\" 2>/dev/null; then
exec tmux attach -t \"$sess\"
fi
proj_dir=\"$remote_workspaces/$arg\"
[ ! -d \"\$proj_dir\" ] && proj_dir=\"$remote_workspaces/$project\"
[ ! -d \"\$proj_dir\" ] && proj_dir=\"\$HOME\"
cd \"\$proj_dir\" && exec tmux new-session -s \"$sess\"
'"
fi
}
EOF
1.5.3 在 Mac ~/.zshrc 中单行载入
在 ~/.zshrc 末尾仅需追加一行保护加载语句,即可完成全套工具链挂载:
# 加载 cx 远程终端与开发工具包
[ -f "$HOME/.config/cx/cx.zsh" ] && source "$HOME/.config/cx/cx.zsh"
第二部分:远程服务器端配置(AI 自动执行指南)
在目标 Linux 服务器执行以下步骤(在当前用户环境下执行,无需 root 权限)。
2.1 部署 Tmux 3.5a 与 ImageMagick 桥接
下载并解压 tmux 3.5a 运行包:
mkdir -p ~/.local/bin ~/.local/share /tmp/tmux_install
cd /tmp/tmux_install
curl -sL https://github.com/nelsonenzo/tmux-appimage/releases/download/3.5a/tmux.appimage -o tmux.appimage
chmod +x tmux.appimage
./tmux.appimage --appimage-extract
rm -rf ~/.local/share/tmux-3.5a
mv squashfs-root ~/.local/share/tmux-3.5a
rm -rf /tmp/tmux_install
# 编写 wrapper 脚本
cat << 'BASH' > ~/.local/bin/tmux
#!/bin/bash
DIR="$HOME/.local/share/tmux-3.5a"
export TERMINFO="$DIR/usr/share/terminfo"
export LD_LIBRARY_PATH="$DIR/usr/lib:$LD_LIBRARY_PATH"
exec "$DIR/usr/bin/tmux" "$@"
BASH
chmod +x ~/.local/bin/tmux
# 建立 ImageMagick 7 兼容软链
which convert >/dev/null && ln -sf $(which convert) ~/.local/bin/magick
2.2 配置 Tmux (~/.tmux.conf)
写入 ~/.tmux.conf:
# 1. 开启鼠标支持与高历史缓冲区
set -g mouse on
set -g history-limit 50000
# 2. 终端类型与真彩色
set -g default-terminal "xterm-256color"
set -as terminal-overrides ",*:Tc"
# 3. 编号从 1 开始
set -g base-index 1
setw -g pane-base-index 1
set -g renumber-windows on
# 4. 状态栏样式
set -g status-style "bg=#181825,fg=#cdd6f4"
set -g status-left "#[fg=#89b4fa,bold] [#S] "
set -g status-right "#[fg=#a6adc8]%Y-%m-%d %H:%M "
setw -g window-status-current-style "fg=#a6e3a1,bold"
# 5. 保留默认前缀键 Ctrl+b(严禁占用 Ctrl+a,确保命令行 Ctrl+a 跳行首、Ctrl+e 跳行末完好无损)
# (无需额外配置,tmux 默认前缀即为 Ctrl+b)
# 6. 驯服触控板滚轮速度(平稳降至 2 行)
bind-key -T copy-mode WheelUpPane select-pane \; send-keys -X -N 2 scroll-up
bind-key -T copy-mode WheelDownPane select-pane \; send-keys -X -N 2 scroll-down
bind-key -T copy-mode-vi WheelUpPane select-pane \; send-keys -X -N 2 scroll-up
bind-key -T copy-mode-vi WheelDownPane select-pane \; send-keys -X -N 2 scroll-down
# 7. 开启图形协议透传 (Kitty Graphics / Sixel 原生看图必备)
set -g allow-passthrough on
set -ga update-environment TERM
set -ga update-environment TERM_PROGRAM
2.3 部署服务器端跨端工具 (pi, pf, up, down, cb, wto)
在 ~/.local/bin/ 创建工具:
cat << 'BASH' > ~/.local/bin/pi
#!/bin/bash
# ==============================================================================
# pi (Paste It) / pf (Paste File)
# 智能粘贴:支持 Mac 截图图片、Mac Finder 复制的文件 (Cmd+C)、Mac 剪贴板文件路径
# ==============================================================================
STATUS_JSON=$(curl -s --max-time 3 http://127.0.0.1:19222/clipboard-status 2>/dev/null)
if [ -z "$STATUS_JSON" ]; then
echo -e "\033[31m✘ [Mac ➔ Server]\033[0m 无法连接 Mac 本地桥接服务 (HTTP 19222)"
exit 1
fi
TYPE=$(echo "$STATUS_JSON" | python3 -c "import sys, json; data=json.load(sys.stdin); print(data.get('type', 'empty'))" 2>/dev/null)
if [ "$TYPE" = "files" ]; then
echo "$STATUS_JSON" | python3 -c "
import sys, json, urllib.parse, subprocess, os
data = json.load(sys.stdin)
files = data.get('files', [])
custom_name = sys.argv[1] if len(sys.argv) > 1 else ''
for i, f in enumerate(files):
remote_path = f['path']
orig_name = f['name']
target_name = custom_name if (custom_name and len(files) == 1) else orig_name
encoded = urllib.parse.quote(remote_path)
url = f'http://127.0.0.1:19222/fetch-file?path={encoded}'
res = subprocess.run(['curl', '-s', '-o', target_name, '-w', '%{http_code}', url], capture_output=True, text=True)
http_code = res.stdout.strip()
if http_code == '200' and os.path.exists(target_name):
size = os.path.getsize(target_name)
if size < 1024:
size_str = f'{size} B'
elif size < 1024 * 1024:
size_str = f'{size / 1024:.1f} KB'
else:
size_str = f'{size / (1024 * 1024):.1f} MB'
print(f'\033[32m✔ [Mac ➔ Server]\033[0m 成功从 Mac 剪贴板粘贴文件: \033[1m{target_name}\033[0m ({size_str})')
else:
print(f'\033[31m✘ [Mac ➔ Server]\033[0m 拉取文件失败: {orig_name} (HTTP {http_code})')
" "$1"
elif [ "$TYPE" = "image" ]; then
TARGET="$1"
[ -z "$TARGET" ] && TARGET="clip_$(date +%Y%m%d_%H%M%S).png"
HTTP_CODE=$(curl -s -o "$TARGET" -w "%{http_code}" http://127.0.0.1:19222/clipboard-image 2>/dev/null || echo "000")
if [ "$HTTP_CODE" = "200" ]; then
SIZE=$(ls -lh "$TARGET" | awk '{print $5}')
echo -e "\033[32m✔ [Mac ➔ Server]\033[0m 成功从剪贴板粘贴图片: \033[1m$TARGET\033[0m ($SIZE)"
else
rm -f "$TARGET"
echo -e "\033[31m✘ [Mac ➔ Server]\033[0m 粘贴图片失败 (HTTP $HTTP_CODE)"
exit 1
fi
elif [ "$TYPE" = "text" ]; then
PREVIEW=$(echo "$STATUS_JSON" | python3 -c "import sys, json; print(json.load(sys.stdin).get('preview', ''))")
echo -e "\033[33mℹ [Mac ➔ Server]\033[0m Mac 剪贴板里是文本内容,非文件或图片:"
echo -e " \033[90m\"$PREVIEW\"\033[0m"
exit 0
else
echo -e "\033[33m⚠ [Mac ➔ Server]\033[0m Mac 剪贴板里当前没有文件或图片数据"
exit 1
fi
BASH
chmod +x ~/.local/bin/pi
ln -sf ~/.local/bin/pi ~/.local/bin/pf
cat << 'BASH' > ~/.local/bin/up
#!/bin/bash
if [ -z "$1" ]; then
echo -e "使用方法: \033[1mup [直接把 Mac 上的图片或文件拖到这里] 回车\033[0m"
exit 1
fi
for FILEPATH in "$@"; do
FILEPATH=$(echo "$FILEPATH" | sed -e "s/^['\"]//" -e "s/['\"]$//")
FILENAME=$(basename "$FILEPATH")
echo -e "正在从 Mac 拉取文件: \033[34m$FILENAME\033[0m ..."
ENCODED_PATH=$(python3 -c "import urllib.parse, sys; print(urllib.parse.quote(sys.argv[1]))" "$FILEPATH")
HTTP_CODE=$(curl -s -o "$FILENAME" -w "%{http_code}" "http://127.0.0.1:19222/fetch-file?path=$ENCODED_PATH" 2>/dev/null)
if [ "$HTTP_CODE" = "200" ]; then
SIZE=$(ls -lh "$FILENAME" | awk '{print $5}')
echo -e "\033[32m✔ [Mac ➔ Server]\033[0m 上传成功: \033[1m$FILENAME\033[0m ($SIZE)"
elif [ "$HTTP_CODE" = "404" ]; then
rm -f "$FILENAME"
echo -e "\033[31m✘ [Mac ➔ Server]\033[0m Mac 本地找不到该文件: $FILEPATH"
elif [ "$HTTP_CODE" = "403" ]; then
rm -f "$FILENAME"
echo -e "\033[31m✘ [Mac ➔ Server]\033[0m Mac 系统权限拒绝读取该文件: $FILEPATH"
else
rm -f "$FILENAME"
echo -e "\033[31m✘ [Mac ➔ Server]\033[0m 拉取失败 (HTTP $HTTP_CODE)"
fi
done
BASH
chmod +x ~/.local/bin/up
cat << 'BASH' > ~/.local/bin/cb
#!/bin/bash
if [ -n "$1" ]; then
echo -n "$1" | curl -s -X POST --data-binary @- http://127.0.0.1:19222/set-clipboard
elif [ ! -t 0 ]; then
curl -s -X POST --data-binary @- http://127.0.0.1:19222/set-clipboard
else
echo "使用方法: echo '文本' | cb 或 cb '文本'"
fi
BASH
chmod +x ~/.local/bin/cb
ln -sf ~/.local/bin/cb ~/.local/bin/wl-copy
ln -sf ~/.local/bin/cb ~/.local/bin/xclip
cat << 'PY' > ~/.local/bin/wto
#!/usr/bin/env python3
# -*- coding: utf-8 -*-
"""
wto (WorkTree Open)
跨 Git Worktree 文件快速定位,fzf 上下键选择,直通 Yazi 或极速本地 HTTP 浏览器预览
"""
import os
import sys
import shutil
import socket
import urllib.parse
import json
import subprocess
import datetime
from pathlib import Path
BOLD = "\033[1m"
GREEN = "\033[38;2;40;167;69m"
CYAN = "\033[38;2;23;162;184m"
YELLOW = "\033[38;2;255;193;7m"
ORANGE = "\033[38;2;253;126;20m"
RED = "\033[38;2;220;53;69m"
GRAY = "\033[38;2;108;117;125m"
RESET = "\033[0m"
def get_server_ip():
conn = os.environ.get("SSH_CONNECTION", "")
if conn:
parts = conn.split()
if len(parts) >= 3:
return parts[2]
res = subprocess.run(["hostname", "-I"], capture_output=True, text=True)
if res.stdout.strip():
return res.stdout.strip().split()[0]
return "127.0.0.1"
def get_git_repo_root():
res = subprocess.run(["git", "rev-parse", "--show-toplevel"], capture_output=True, text=True)
if res.returncode == 0 and res.stdout.strip():
return Path(res.stdout.strip())
return None
def get_all_worktrees(repo_root):
res = subprocess.run(["git", "-C", str(repo_root), "worktree", "list", "--porcelain"], capture_output=True, text=True)
if res.returncode != 0:
return []
worktrees = []
current = {}
for line in res.stdout.splitlines() +[""]:
if not line:
if current and "path" in current:
worktrees.append(current)
current = {}
elif line.startswith("worktree "):
current["path"] = Path(line[9:]).resolve()
current["name"] = current["path"].name
elif line.startswith("branch "):
current["branch"] = line[7:].removeprefix("refs/heads/")
elif line == "detached":
current["branch"] = "(detached)"
return worktrees
def get_file_status(wt_path, rel_path):
abs_path = wt_path / rel_path
if not abs_path.exists():
return None
st_res = subprocess.run(["git", "-C", str(wt_path), "status", "-s", "--", str(rel_path)], capture_output=True, text=True)
st = st_res.stdout.strip()
mtime = os.path.getmtime(abs_path)
dt = datetime.datetime.fromtimestamp(mtime)
time_str = dt.strftime("%m-%d %H:%M")
if st.startswith("??"):
status_label = "[?? 新增]"
status_color = GREEN
elif "M" in st:
status_label = "[M 修改]"
status_color = YELLOW
elif "D" in st:
status_label = "[D 删除]"
status_color = RED
elif st:
status_label = f"[{st[:7]:<7}]"
status_color = CYAN
else:
status_label = "[clean ]"
status_color = GRAY
return {
"status_label": status_label,
"status_color": status_color,
"time_str": time_str,
"abs_path": str(abs_path),
"rel_path": str(rel_path),
"mtime": mtime
}
def find_target_in_worktrees(worktrees, target_input):
if not target_input or not target_input.strip():
return []
# 1. 若传入已存在的确切物理文件(如 Yazi 传参),直接精准返回单项,绝不误弹列表
target_p = Path(target_input).resolve()
if target_p.is_file():
info = get_file_status(target_p.parent, target_p.name)
if info:
for wt in worktrees:
try:
rel = str(target_p.relative_to(wt["path"]))
info["wt"] = wt
info["rel_path"] = rel
info["score"] = 200
return [info]
except ValueError:
pass
info["wt"] = {"name": target_p.parent.name, "path": target_p.parent}
info["score"] = 200
return [info]
target_clean = target_input.strip("/").strip()
target_lower = target_clean.lower()
target_name = Path(target_clean).name.lower()
matches = []
for wt in worktrees:
wt_path = wt["path"]
# 1. 相对路径完全匹配
exact = wt_path / target_clean
if exact.is_file():
info = get_file_status(wt_path, target_clean)
if info:
info["wt"] = wt
info["score"] = 100
matches.append(info)
continue
# 2. 从 git ls-files 或文件树抓取文件列表
files = []
try:
res = subprocess.run(
["git", "-C", str(wt_path), "ls-files", "-c", "-o", "--exclude-standard"],
capture_output=True,
text=True
)
if res.returncode == 0 and res.stdout.strip():
files = [line.strip() for line in res.stdout.splitlines() if line.strip()]
except Exception:
pass
if not files:
for root, dirs, fnames in os.walk(wt_path):
dirs[:] = [d for d in dirs if d not in (".git", ".claude", "node_modules", ".venv", "__pycache__", ".runtime")]
root_p = Path(root)
for fn in fnames:
try:
rel = str((root_p / fn).relative_to(wt_path))
files.append(rel)
except Exception:
pass
# 3. 智能多维度打分模糊匹配
for rel in files:
p = Path(rel)
full_name = p.name.lower()
stem_name = p.stem.lower()
rel_lower = rel.lower()
score = 0
if rel_lower == target_lower or rel_lower.endswith("/" + target_lower):
score = 100
elif full_name == target_name:
score = 90
elif stem_name == target_name:
score = 85 # 省略后缀精准命中(如输入 口播与动作对齐方案 -> 命中 口播与动作对齐方案.md)
elif full_name.startswith(target_name) or stem_name.startswith(target_name):
score = 70
elif target_name in stem_name or target_name in full_name:
score = 60 # 文件名部分包含关键词
elif target_lower in rel_lower:
score = 40 # 路径包含关键词
if score > 0:
info = get_file_status(wt_path, rel)
if info:
info["wt"] = wt
info["score"] = score
matches.append(info)
# 去重并按匹配得分排序
seen = set()
unique = []
matches.sort(key=lambda m: (-m.get("score", 0), len(m["rel_path"])))
for m in matches:
if m["abs_path"] not in seen:
seen.add(m["abs_path"])
unique.append(m)
return unique
def list_all_dirty_files(worktrees):
all_files = []
for wt in worktrees:
wt_path = wt["path"]
res = subprocess.run(["git", "-C", str(wt_path), "status", "-s"], capture_output=True, text=True)
for line in res.stdout.splitlines():
line = line.strip()
if not line:
continue
parts = line.split(maxsplit=1)
if len(parts) == 2:
rel = parts[1]
info = get_file_status(wt_path, rel)
if info:
info["wt"] = wt
all_files.append(info)
all_files.sort(key=lambda x: x["mtime"], reverse=True)
return all_files
def select_with_fzf(hits, prompt_title="⚡ 选择 Worktree 文件"):
if not hits:
return None
if len(hits) == 1:
return hits[0]
if not sys.stdin.isatty():
return hits[0]
lines = []
for h in hits:
wt_name = h["wt"]["name"]
st_color = h["status_color"]
st_label = h["status_label"]
time_str = h["time_str"]
rel = h["rel_path"]
abs_path = h["abs_path"]
display = f"🌿 {BOLD}{CYAN}{wt_name:<26}{RESET} {st_color}{st_label}{RESET} {GRAY}{time_str}{RESET} {rel}"
lines.append(f"{display}\t{abs_path}")
input_text = "\n".join(lines) + "\n"
fzf_cmd = [
"fzf",
f"--prompt={prompt_title}: ",
"--height=40%",
"--layout=reverse",
"--border",
"--ansi",
"--delimiter=\t",
"--with-nth=1",
"--select-1",
"--exit-0"
]
try:
p = subprocess.Popen(fzf_cmd, stdin=subprocess.PIPE, stdout=subprocess.PIPE, text=True)
out, _ = p.communicate(input=input_text)
if p.returncode != 0 or not out.strip():
return None
selected_line = out.strip()
parts = selected_line.split("\t")
target_path = parts[-1]
matching_hit = next((h for h in hits if h["abs_path"] == target_path), None)
return matching_hit or {"abs_path": target_path}
except Exception as e:
print(f"fzf 启动失败: {e}", file=sys.stderr)
return None
def print_help():
print(f"""
{BOLD}{GREEN}wto (Worktree Open){RESET} - 跨 Worktree 智能文件检索与秒开工具
{BOLD}用法:{RESET}
{CYAN}wto{RESET} 扫描所有活跃 Worktree 中未提交的改动,上下键挑选后在 Yazi 打开
{CYAN}wto <文件名或相对路径>{RESET} 跨所有活跃 Worktree 智能模糊匹配该文件,回车直通 Yazi
{CYAN}wto -h <文件名>{RESET} 原地启动轻量 HTTP 服务,渲染 Markdown ({BOLD}含 Mermaid 流程图{RESET}) 或 HTML,
并自动唤醒 Mac 浏览器打开;{GREEN}生成的内网链接可直接复制分享给同事!{RESET}
{CYAN}wto -h{RESET} 扫描所有改动文件,上下键挑选后原地起 HTTP 服务在浏览器打开
{CYAN}wto --help{RESET} 显示本使用说明
{BOLD}快捷参数:{RESET}
{YELLOW}-h, --html, --browser, -w{RESET} 启用浏览器预览模式(原地起 HTTP 服务 + 浏览器打开)
{YELLOW}--help{RESET} 查看帮助信息
""")
def start_browser_preview(file_path, wt_info=None):
file_p = Path(file_path).resolve()
if not file_p.exists():
print(f"{RED}文件不存在: {file_path}{RESET}")
return
target_dir = file_p.parent
file_name = file_p.name
# 寻找空闲端口 (3480..3500, 18080..18100)
port = 0
for p in list(range(3480, 3500)) + list(range(18080, 18100)):
with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s:
try:
s.bind(('0.0.0.0', p))
port = p
break
except OSError:
continue
if not port:
port = 18080
server_ip = get_server_ip()
class RawFileHandler(http.server.SimpleHTTPRequestHandler):
def __init__(self, *args, **kwargs):
super().__init__(*args, directory=str(target_dir), **kwargs)
def guess_type(self, path):
p = str(path).lower()
if p.endswith((".md", ".markdown")):
return "text/markdown; charset=utf-8"
elif p.endswith((".html", ".htm")):
return "text/html; charset=utf-8"
elif p.endswith((".txt", ".log", ".prompt")):
return "text/plain; charset=utf-8"
return super().guess_type(path)
def do_GET(self):
parsed = urllib.parse.urlparse(self.path)
clean_path = urllib.parse.unquote(parsed.path.lstrip("/"))
if not clean_path:
self.send_response(302)
self.send_header("Location", "/" + urllib.parse.quote(file_name))
self.end_headers()
return
super().do_GET()
def log_message(self, format, *args):
pass
preview_url = f"http://{server_ip}:{port}/{urllib.parse.quote(file_name)}"
# 唤醒 Mac 本地默认浏览器
bridge_url = f"http://127.0.0.1:19222/open-browser?url={urllib.parse.quote(preview_url)}"
notify_res = subprocess.run(["curl", "-s", "--max-time", "2", bridge_url], capture_output=True, text=True)
auto_opened = (notify_res.stdout.strip() == "ok")
print(f"\n{BOLD}{GREEN}🌐 原地 HTTP 预览服务已就绪!{RESET}")
print(f"🔗 访问地址 {BOLD}: {BOLD}{CYAN}{preview_url}{RESET}")
if auto_opened:
print(f"{GREEN}✔ 成功唤醒 Mac 本地浏览器打开该文件(原样返回,交由浏览器插件高保真渲染)!{RESET}")
else:
print(f"{YELLOW}💡 提示:在 Ghostty 终端按住 Cmd 点击上方链接即可在 Mac 浏览器打开{RESET}")
print(f"{GRAY}(按 Ctrl+C 停止服务并返回终端){RESET}\n")
class ThreadedHTTPServer(socketserver.ThreadingTCPServer):
daemon_threads = True
allow_reuse_address = True
try:
with ThreadedHTTPServer(("0.0.0.0", port), RawFileHandler) as httpd:
try:
httpd.serve_forever()
except KeyboardInterrupt:
pass
finally:
try:
httpd.server_close()
except Exception:
pass
except Exception:
pass
print(f"\n{GRAY}HTTP 预览服务已关闭。{RESET}", flush=True)
os._exit(0)
def main():
args = sys.argv[1:]
# 处理 --help
if any(a in ("--help", "-help", "help") for a in args):
print_help()
sys.exit(0)
is_http_mode = False
filtered_args = []
for arg in args:
if arg in ("-h", "--html", "--http", "--browser", "-w"):
is_http_mode = True
else:
filtered_args.append(arg)
target = filtered_args[0] if filtered_args else None
repo = get_git_repo_root()
if not repo:
print(f"{RED}✘ 错误:当前目录不在 Git 仓库内!{RESET}")
sys.exit(1)
worktrees = get_all_worktrees(repo)
if not worktrees:
print(f"{RED}✘ 错误:未读取到任何 worktree 信息!{RESET}")
sys.exit(1)
if target:
hits = find_target_in_worktrees(worktrees, target)
else:
hits = list_all_dirty_files(worktrees)
if not hits:
print(f"{YELLOW}⚠️ 未在任何 Worktree 中找到匹配的文件:{target or '(所有工作区无未提交改动)'}{RESET}")
sys.exit(0)
prompt = "🌐 选择文件 (回车启动 HTTP 并打开 Mac 浏览器)" if is_http_mode else "⚡ 选择文件 (回车直通 Yazi)"
hit = select_with_fzf(hits, prompt_title=prompt)
if not hit:
sys.exit(0)
selected_file = hit["abs_path"]
cb_bin = shutil.which("cb") or os.path.expanduser("~/.local/bin/cb")
if os.path.exists(cb_bin):
subprocess.run([cb_bin, selected_file], capture_output=True)
if is_http_mode:
start_browser_preview(selected_file, hit)
else:
yazi_bin = shutil.which("yazi") or os.path.expanduser("~/.local/bin/yazi")
if not os.path.exists(yazi_bin):
print(f"{RED}✘ 错误:未找到 yazi 二进制文件!{RESET}")
sys.exit(1)
os.execv(yazi_bin, [yazi_bin, selected_file])
if __name__ == "__main__":
try:
main()
except KeyboardInterrupt:
sys.exit(0)
PY
chmod +x ~/.local/bin/wto
cat << 'PY' > ~/.local/bin/down
#!/usr/bin/env python3
# ==============================================================================
# down (Download to Mac) - 一键将服务器文件/目录极速下载到 Mac 的 ~/Downloads
# ==============================================================================
import sys
import os
import urllib.parse
import urllib.request
import json
import tarfile
import tempfile
import subprocess
import shutil
BRIDGE_URL = "http://127.0.0.1:19222"
HELP_TEXT = """\033[1mdown\033[0m (Download to Mac) - 一键将服务器文件下载到 Mac 的 ~/Downloads
\033[1m用法:\033[0m
down <文件或目录>... 将一个或多个文件直接下载到 Mac 的 ~/Downloads
down 不带参数时,交互式弹出文件列表(fzf 上下键挑选秒下载)
down --help, -h 查看帮助说明
\033[1m亮点:\033[0m
• 自动识别目录:指定目录时会自动压缩为 .tar.gz 传回 Mac
• 零等待唤醒:文件传完后 Mac 会自动在 Finder 中高亮选中该文件
• 重名保护:若 Mac 的 ~/Downloads 已有同名文件,自动追加 (1), (2) 防覆盖
"""
def format_size(size_bytes):
if size_bytes < 1024:
return f"{size_bytes} B"
elif size_bytes < 1024 * 1024:
return f"{size_bytes / 1024:.1f} KB"
else:
return f"{size_bytes / (1024 * 1024):.1f} MB"
def check_bridge():
try:
req = urllib.request.Request(f"{BRIDGE_URL}/ping", method="GET")
with urllib.request.urlopen(req, timeout=2) as resp:
return resp.status == 200
except Exception:
return False
def upload_file(local_path, custom_name=None):
if not os.path.exists(local_path):
print(f"\033[31m✘ [Server ➔ Mac]\033[0m 文件不存在: {local_path}")
return False
is_temp_tar = False
actual_path = local_path
filename = custom_name or os.path.basename(local_path.rstrip("/\\"))
# 如果是目录,自动打包为 tar.gz
if os.path.isdir(local_path):
clean_dir = os.path.normpath(local_path)
base_name = os.path.basename(clean_dir) or "archive"
filename = f"{base_name}.tar.gz"
print(f"\033[34mℹ [Server ➔ Mac]\033[0m 检测到目录,正在打包压缩为 \033[1m{filename}\033[0m ...")
temp_file = tempfile.NamedTemporaryFile(suffix=".tar.gz", delete=False)
temp_file.close()
try:
with tarfile.open(temp_file.name, "w:gz") as tar:
tar.add(local_path, arcname=base_name)
actual_path = temp_file.name
is_temp_tar = True
except Exception as e:
print(f"\033[31m✘ [Server ➔ Mac]\033[0m 打包目录失败: {e}")
if os.path.exists(temp_file.name):
os.unlink(temp_file.name)
return False
file_size = os.path.getsize(actual_path)
url = f"{BRIDGE_URL}/save-file?filename={urllib.parse.quote(filename)}&reveal=1"
try:
with open(actual_path, "rb") as f:
req = urllib.request.Request(
url,
data=f,
headers={
"Content-Type": "application/octet-stream",
"Content-Length": str(file_size)
},
method="POST"
)
with urllib.request.urlopen(req, timeout=180) as resp:
result = json.loads(resp.read().decode("utf-8"))
saved_name = result.get("filename", filename)
size_str = format_size(result.get("size", file_size))
print(f"\033[32m✔ [Server ➔ Mac]\033[0m 成功下载到 Mac: \033[1m~/Downloads/{saved_name}\033[0m ({size_str})")
return True
except Exception as e:
print(f"\033[31m✘ [Server ➔ Mac]\033[0m 下载失败 ({filename}): {e}")
return False
finally:
if is_temp_tar and os.path.exists(actual_path):
try:
os.unlink(actual_path)
except Exception:
pass
def pick_file_fzf():
if not shutil.which("fzf"):
print("\033[31m✘ [Server ➔ Mac]\033[0m 未指定文件且系统未安装 fzf,请直接指定文件名,如: down <文件名>")
return None
find_cmd = [
"find", ".",
"-maxdepth", "3",
"-not", "-path", "*/.*",
"-not", "-path", "*/node_modules/*",
"-not", "-path", "*/.venv/*",
"-not", "-path", "*/__pycache__/*",
"-type", "f"
]
fzf_cmd = [
"fzf",
"--height=40%",
"--reverse",
"--prompt=选择要下载到 Mac 的文件 > ",
"--preview=head -n 20 {} 2>/dev/null"
]
try:
p1 = subprocess.Popen(find_cmd, stdout=subprocess.PIPE, stderr=subprocess.DEVNULL)
p2 = subprocess.Popen(fzf_cmd, stdin=p1.stdout, stdout=subprocess.PIPE, text=True)
p1.stdout.close()
out, _ = p2.communicate()
selected = out.strip()
return selected if selected else None
except Exception:
return None
def main():
args = sys.argv[1:]
if args and args[0] in ("-h", "--help", "help"):
print(HELP_TEXT)
return
if not check_bridge():
print("\033[31m✘ [Server ➔ Mac]\033[0m 无法连接 Mac 本地桥接服务 (HTTP 19222)")
print(" 提示: 请检查是否通过带有端口转发的 SSH 登录 (RemoteForward 19222 127.0.0.1:19222)")
sys.exit(1)
if not args:
selected = pick_file_fzf()
if not selected:
sys.exit(0)
upload_file(selected)
else:
for path in args:
upload_file(path)
if __name__ == "__main__":
main()
PY
chmod +x ~/.local/bin/down
2.4 安装部署 Yazi (v26.9+) 与插件套件
安装最新版纯静态 Yazi:
curl -sL https://github.com/sxyazi/yazi/releases/download/v26.9.1/yazi-x86_64-unknown-linux-musl.zip -o /tmp/yazi.zip
unzip -o /tmp/yazi.zip -d /tmp/yazi_bin
mv /tmp/yazi_bin/yazi-x86_64-unknown-linux-musl/yazi ~/.local/bin/yazi
mv /tmp/yazi_bin/yazi-x86_64-unknown-linux-musl/ya ~/.local/bin/ya
chmod +x ~/.local/bin/yazi ~/.local/bin/ya
rm -rf /tmp/yazi.zip /tmp/yazi_bin
安装并配置四大专属插件:
zoom.yazi(自适应等比铺满与按键缩放)piper.yazi(官方通用流预览)paste-img.yazi(按P直接粘贴 Mac 截图)copy-mac.yazi(按c c把绝对路径直接复制到 Mac)
创建 ~/.config/yazi/plugins/zoom.yazi/main.lua:
--- @since 26.1.22
ya.clone = ya.clone or Url
local get = ya.sync(function(st, url) return st.last == url and st.level end)
local save = ya.sync(function(st, url, new)
local h = cx.active.current.hovered
if h and h.url == url then
st.last, st.level = url, new
return true
end
end)
local lock = ya.sync(function(st, url, old, new)
if st.last == url and st.level == old then
st.level = new
return true
end
end)
local move = ya.sync(function(st)
local h = cx.active.current.hovered
if not h then return end
if st.last ~= h.url then st.last, st.level = ya.clone(h.url), 0 end
return { url = h.url, level = st.level }
end)
local function end_(job, err)
if not job.old_level then
ya.preview_widget(job, err and ui.Text(err):area(job.area):wrap(ui.Wrap.YES))
elseif err then
ya.notify { title = "Zoom", content = tostring(err), timeout = 5, level = "error" }
end
end
local function canvas(area)
local cw, ch = rt.term.cell_size()
if not cw or cw <= 0 or ch <= 0 then
return rt.preview.max_width, rt.preview.max_height
end
return math.min(rt.preview.max_width, math.floor(area.w * cw)),
math.min(rt.preview.max_height, math.floor(area.h * ch))
end
local function peek(_, job)
local url = job.file.url
local info, err = ya.image_info(url)
if not info then
return end_(job, Err("Failed to get image info: %s", err))
end
local level = ya.clamp(-10, job.new_level or get(ya.clone(url)) or tonumber(job.args[1]) or 0, 10)
local sync = function()
if job.old_level then return lock(url, job.old_level, level) else return save(url, level) end
end
local max_w, max_h = canvas(job.area)
local scale_w, scale_h = max_w / info.w, max_h / info.h
local base_scale = math.min(scale_w, scale_h)
local base_w = math.max(1, math.floor(info.w * base_scale))
local base_h = math.max(1, math.floor(info.h * base_scale))
local factor = 1.0 + (level * 0.15)
if factor < 0.1 then factor = 0.1 end
local new_w = math.min(max_w, math.floor(base_w * factor))
local new_h = math.min(max_h, math.floor(base_h * factor))
local tmp = os.tmpname()
local output, err = Command("magick"):arg {
tostring(job.file.path),
"-auto-orient", "-strip",
"-sample", string.format("%dx%d", new_w, new_h),
"-quality", rt.preview.image_quality,
string.format("WEBP:%s", tmp),
}:output()
if not output then
end_(job, Err("Failed to start `magick`: %s", err))
elseif not output.status.success then
end_(job, Err("`magick` error %s: %s", output.status.code, output.stderr))
elseif sync() then
ya.image_show(Url(tmp), job.area)
end
end_(job)
end
local function entry(self, job)
local st = move()
if not st then return end
local motion = tonumber(job.args[1]) or 0
local new = (job.args[1] == "0" or motion == 0) and 0 or ya.clamp(-10, st.level + motion, 10)
if new ~= st.level or job.args[1] == "0" then
Stat = Stat or Cha
local stat = Stat { mode = tonumber("100644", 8) }
peek(self, {
area = ui.area("preview"),
args = {},
file = File { url = st.url, cha = stat, stat = stat, lstat = stat },
skip = 0,
new_level = new,
old_level = st.level,
})
end
end
return { peek = peek, entry = entry }
创建 ~/.config/yazi/plugins/copy-mac.yazi/main.lua:
--- @sync entry
return {
entry = function(self, job)
local h = cx.active.current.hovered
if not h then return end
local text = ""
local kind = job.args[1] or "path"
if kind == "path" then
text = tostring(h.url)
elseif kind == "filename" or kind == "f" then
text = tostring(h.name)
elseif kind == "dirpath" or kind == "d" then
text = tostring(cx.active.current.cwd)
elseif kind == "name_without_ext" or kind == "n" then
text = tostring(h.name:gsub("%.%w+$", ""))
else
text = tostring(h.url)
end
local child = Command("sh")
:arg({ "-c", "curl -s -X POST --data-binary @- http://127.0.0.1:19222/set-clipboard" })
:stdin(Command.PIPED)
:spawn()
if child then
child:write_all(text)
child:flush()
child:wait()
ya.notify { title = "Copied to Mac", content = text, timeout = 2, level = "info" }
else
ya.notify { title = "Copy Failed", content = "无法连接 Mac 剪贴板", timeout = 3, level = "error" }
end
end,
}
创建 ~/.config/yazi/plugins/paste-img.yazi/main.lua:
--- @sync entry
return {
entry = function()
local child, err = Command("pi"):spawn()
if not child then
ya.notify { title = "Paste", content = "启动失败: " .. tostring(err), timeout = 3, level = "error" }
return
end
local status = child:wait()
if status and status.success then
ya.notify { title = "Paste", content = "✔ 剪贴板内容已成功粘贴入当前目录!", timeout = 3, level = "info" }
else
ya.notify { title = "Paste", content = "✘ 粘贴失败:剪贴板无数据或桥接未连接", timeout = 3, level = "warn" }
end
end,
}
写入配置文件 ~/.config/yazi/yazi.toml:
[preview]
max_width = 3840
max_height = 2160
image_quality = 85
[plugin]
prepend_previewers = [
{ url = "*.{png,jpg,jpeg,webp,gif,bmp,PNG,JPG,JPEG,WEBP,GIF,BMP}", run = "zoom" },
{ mime = "image/*", run = "zoom" },
{ url = "*.md", run = 'piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dark "$1"' },
{ url = "*.markdown", run = 'piper -- CLICOLOR_FORCE=1 glow -w=$w -s=dark "$1"' },
]
写入配置文件 ~/.config/yazi/keymap.toml:
# 窗口全屏放大/恢复
[[mgr.prepend_keymap]]
on = "T"
run = "plugin toggle-pane max-preview"
desc = "全屏放大 / 恢复右侧预览窗口"
# 图片缩放
[[mgr.prepend_keymap]]
on = "+"
run = "plugin zoom 1"
desc = "图片预览放大"
[[mgr.prepend_keymap]]
on = "="
run = "plugin zoom 1"
desc = "图片预览放大"
[[mgr.prepend_keymap]]
on = "-"
run = "plugin zoom -1"
desc = "图片预览缩小"
[[mgr.prepend_keymap]]
on = "0"
run = "plugin zoom 0"
desc = "重置图片缩放比例"
# 从 Mac 粘贴截图
[[mgr.prepend_keymap]]
on = "P"
run = "plugin paste-img"
desc = "从 Mac 剪贴板直接粘贴图片或文件到当前目录"
# 路径复制直达 Mac 剪贴板
[[mgr.prepend_keymap]]
on = [ "c", "c" ]
run = "plugin copy-mac -- path"
desc = "复制文件绝对路径到 Mac 剪贴板"
[[mgr.prepend_keymap]]
on = [ "c", "f" ]
run = "plugin copy-mac -- filename"
desc = "复制文件名到 Mac 剪贴板"
[[mgr.prepend_keymap]]
on = [ "c", "d" ]
run = "plugin copy-mac -- dirpath"
desc = "复制当前目录路径到 Mac 剪贴板"
[[mgr.prepend_keymap]]
on = [ "c", "n" ]
run = "plugin copy-mac -- name_without_ext"
desc = "复制无后缀文件名到 Mac 剪贴板"
# 预览区滚动
[[mgr.prepend_keymap]]
on = [ "<C-e>" ]
run = "seek 5"
desc = "预览窗口向下滚动 5 行"
[[mgr.prepend_keymap]]
on = [ "<C-y>" ]
run = "seek -5"
desc = "预览窗口向上滚动 5 行"
# 在 Mac 浏览器中通过 HTTP 打开当前文件
[[mgr.prepend_keymap]]
on = "B"
run = 'shell "wto -h %h" --block'
desc = "在 Mac 浏览器中 HTTP 预览当前文件"
[[mgr.prepend_keymap]]
on = "b"
run = 'shell "wto -h %h" --block'
desc = "在 Mac 浏览器中 HTTP 预览当前文件"
2.6 终端美化与极简提示符 (~/.config/starship.toml)
采用 Catppuccin 渐变胶囊样式,并彻底去除 $?: 等令人眼花的 Git 杂乱状态符号,保持终端极致清爽:
cat << 'TOML' > ~/.config/starship.toml
"$schema" = 'https://starship.rs/config-schema.json'
format = """
[](red)\
$os\
$username\
[](bg:peach fg:red)\
$directory\
[](bg:yellow fg:peach)\
$git_branch\
$git_status\
[ ](yellow)\
$character"""
palette = 'catppuccin_mocha'
[os]
disabled = false
style = "bg:red fg:crust"
[os.symbols]
Macos = ""
Ubuntu = ""
Linux = ""
[username]
show_always = true
style_user = "bg:red fg:crust"
style_root = "bg:red fg:crust"
format = '[[ $user ](fg:crust bg:red)]($style)'
[directory]
style = "fg:crust bg:peach"
format = "[ $path ]($style)"
truncation_length = 3
truncation_symbol = "…/"
[git_branch]
symbol = ""
style = "bg:yellow"
format = '[[ $symbol $branch ](fg:crust bg:yellow)]($style)'
# 极简模式:关闭复杂的 stash/untracked 符号($?:),仅保留干净的分支名
[git_status]
disabled = true
style = "bg:yellow"
format = '[[($all_status$ahead_behind )](fg:crust bg:yellow)]($style)'
[nodejs]
disabled = true
[time]
disabled = true
[cmd_duration]
disabled = true
[character]
disabled = false
success_symbol = '[❯](bold green)'
error_symbol = '[❯](bold red)'
[palettes.catppuccin_mocha]
rosewater = "#f5e0dc"
flamingo = "#f2cdcd"
pink = "#f5c2e7"
mauve = "#cba6f7"
red = "#f38ba8"
maroon = "#eba0ac"
peach = "#fab387"
yellow = "#f9e2af"
green = "#a6e3a1"
teal = "#94e2d5"
sky = "#89dceb"
sapphire = "#74c7ec"
blue = "#89b4fa"
lavender = "#b4befe"
text = "#cdd6f4"
subtext1 = "#bac2de"
subtext0 = "#a6adc8"
overlay2 = "#9399b2"
overlay1 = "#7f849c"
overlay0 = "#6c7086"
surface2 = "#585b70"
surface1 = "#45475a"
surface0 = "#313244"
base = "#1e1e2e"
mantle = "#181825"
crust = "#11111b"
TOML
2.7 部署远程 Shell 快捷别名与工作流 (~/.bashrc)
在远程服务器的 ~/.bashrc 末尾添加如下快捷别名与函数,实现工作区秒切、Tmux 窗口无痛管理与神级终端工具链集成:
# ------------------------------------------------------------------------------
# 远程开发快捷别名与工作流
# ------------------------------------------------------------------------------
# 1. 项目直达
alias ai3='cd ~/workspaces/<PROJECT_NAME> && tmux new -A -s <PROJECT_NAME>'
alias cdai='cd ~/workspaces/<PROJECT_NAME>'
# 2. 终端神级工具链
alias lg='lazygit' # Git TUI 神器(按 w 看/切 worktree,按 3 看 commit 树)
alias y='~/.local/bin/yazi'
alias yazi='~/.local/bin/yazi' # 现代化文件管理器(方向键穿梭,Kitty 原生看图)
alias wt='git worktree list' # 查看当前项目所有 git worktree 工作树
alias gw='glow'
# 3. Tmux 窗口快捷操作(保持当前目录)
alias new='tmux new-window -c "$(pwd)"'
alias cw='new'
alias rn='tmux rename-window'
alias close='exit'
alias tls='tmux ls'
# 4. 函数定义(规范无别名冲突)
ta() {
tmux new -A -s "${1:-main}"
}
md() {
if [ -z "$1" ]; then
glow
else
glow -p "$@"
fi
}
ws() {
local target_dir
if [ -z "$1" ]; then
target_dir=$(find ~/workspaces -mindepth 1 -maxdepth 1 -type d -exec basename {} \; 2>/dev/null | sort | fzf --prompt="⚡ 选择工作区项目: " --height=40% --layout=reverse --border)
[ -z "$target_dir" ] && return 0
else
target_dir="$1"
fi
local full_path="$HOME/workspaces/$target_dir"
if [ -d "$full_path" ]; then
cd "$full_path"
tmux new -A -s "$target_dir"
else
echo "未找到目录: $full_path"
fi
}
tkill() {
local target="$1"
if [ -z "$target" ]; then
target=$(tmux list-sessions -F "#{session_name}" 2>/dev/null | fzf --prompt="🗑️ 选择要关闭的保活会话: " --height=40% --layout=reverse --border)
[ -z "$target" ] && return 0
fi
if [ "$target" = "all" ]; then
tmux kill-server
echo "✔ 已关闭全部后台保活会话"
else
tmux kill-session -t "$target" 2>/dev/null && echo "✔ 已关闭会话: $target" || echo "✖ 未找到会话: $target"
fi
}
# 加载 Starship 彩虹状态栏(Catppuccin Powerline)
if [ -x "$HOME/.local/bin/starship" ]; then
eval "$($HOME/.local/bin/starship init bash)"
fi
第三部分:完整验证核对清单(验收流程)
按顺序执行以下命令验证全链路是否正常:
| 验证项 | 执行命令 | 期望输出 / 正确现象 |
|---|---|---|
| 1. Mac 本地桥接服务 | curl -s http://127.0.0.1:19222/ping |
输出 pong |
| 2. 连入服务器反向隧道 | cx 然后在服务器执行 curl -s http://127.0.0.1:19222/ping |
输出 pong(证明隧道通畅) |
| 3. Tmux 版本与选项 | 在服务器执行 tmux -V && tmux show -g allow-passthrough |
输出 tmux 3.5a 与 allow-passthrough on |
| 4. 粘贴 Mac 截图或文件 | 在 Mac 截图或在 Finder 复制文件后,在服务器执行 pi |
输出 ✔ [Mac ➔ Server] 成功从剪贴板粘贴图片/文件 |
| 5. 拖拽文件秒传 | 在服务器执行 up /Users/.../test.png |
输出 ✔ [Mac ➔ Server] 上传成功 |
| 6. Yazi 路径复制到 Mac | 在服务器运行 y,光标停在文件敲两次 c |
右上角提示 Copied to Mac,回 Mac 按 Cmd+V 成功粘贴 |
| 7. 图片看图与缩放 | 在 Yazi 停在图片上按 T |
图片瞬间铺满全屏,按 + 进一步放大,按 0 还原 |
| 8. 跨 Worktree 智能检索 | 在服务器执行 wto 或 wto <文件> |
底部弹起 40% fzf 窗口,上下键移动,回车直通 Yazi |
| 9. 浏览器原地文件预览 | 在服务器执行 wto -h <文件> |
原地启动轻量 HTTP 服务,原样返回文件(支持浏览器 Markdown 插件高保真渲染),自动唤醒 Mac 浏览器打开,内网链接可直接发给同事,Ctrl+C 退出 |
| 10. 服务器文件秒传回 Mac | 在服务器执行 down <文件或目录> |
输出 ✔ [Server ➔ Mac] 成功下载到 Mac: ~/Downloads/...,Mac Finder 自动聚焦高亮选中 |
第四部分:日常操作随手备忘
# --- 会话连接与管理 (Mac 本地,配置解耦自 ~/.config/cx/config) ---
cx # 默认直达主项目会话
cx new # 自动新建独立序号会话 (my_project_1, _2 ...)
cx 1 / cx 2 # 直接进对应序号会话
cx ls # 查看后台有哪些会话在跑 (免端口转发冲突)
cx k # 交互式选择关闭会话
cx ka # 一键关闭全部后台会话
cx config # 查看/切换当前目标服务器与项目配置
cx bridge # 查看本地跨端桥接服务运行状态 (127.0.0.1:19222)
cx bridge restart # 一键重启本地桥接服务 (解决端口阻塞或异常)
cx -h / cx h # 查看 cx 命令行帮助与中文说明 (亦支持 help / --help)
# --- 远程工作区直达与会话调度 (远程服务器) ---
ws # fzf 弹出工作区列表,上下键挑选回车直达项目并挂接 tmux
ws <项目名> # 直接切到指定项目目录并挂入对应会话
ai3 # 秒进主项目并恢复/挂接主会话
cdai # 纯切到主项目目录
ta [会话名] # 挂接指定会话(默认 main,不存在则新建)
tls # 查看所有后台 tmux 会话
tkill # fzf 交互式挑着杀掉会话
tkill all # 一键杀掉全部后台保活会话
# --- Tmux 窗口极速操作 (远程服务器内) ---
new / cw # 在当前工作目录下新建 tmux 窗口(保持当前 pwd,不跳回根目录)
rn # 重命名当前 tmux 窗口
close # 退出当前窗格或窗口 (exit)
# --- Git Worktree 与文档速查 (远程服务器) ---
wt # 一键查看当前仓库所有 Git Worktree 工作树及分支
lg # 启动 Lazygit(按 w 查/切 worktree,按 3 查 commit 树)
md <文件> # Glow 彩色高亮分页预览 Markdown 文档
md # Glow 交互式浏览当前目录所有文档
gw # glow 原生命令别名
# --- 跨端文件流转与 Worktree 检索 (远程服务器) ---
pi / pf # 智能粘贴:Mac 截图像图贴图;Finder 复制文件(Cmd+C)则原名拉取!
pi test.png # 指定文件名保存(仅单文件/截图时生效)
up [把文件拖进来] # 直接把本地文件拉取到当前目录
down <文件/目录> # 一键秒下到 Mac ~/Downloads,目录自动打 .tar.gz,Finder 自动高亮!
down # 不带参数:fzf 上下键挑选文件极速下载到 Mac
down --help # 查看 down 命令行帮助
wto <文件名> # 跨所有 Worktree 智能匹配该文件,fzf 上下键选择,回车直通 Yazi 并高亮
wto -h <文件名> # 原地起 HTTP 服务,原样返回文件(交由浏览器 Markdown 插件高保真渲染),链接可直接发同事,Ctrl+C 退出
wto -h # 扫描所有 Worktree 中近期改动的未提交代码,上下键挑选后起 HTTP 浏览器查看
wto --help # 查看 wto 命令行帮助与用法说明
# --- Yazi 专属神键 (远程服务器) ---
y # 启动 Yazi
q # 普通退出
Q (大写) # 退出并自动 cd 到最后选中的目录(极力推荐)
c c # 复制选中文件的绝对路径到 Mac 剪贴板!
c f # 复制文件名到 Mac 剪贴板
P (大写) # 直接从 Mac 剪贴板粘贴最新截图或复制的文件并高亮展示
T (大写) # 全屏放大/还原右侧预览(图片自适应撑满,文档宽幅排版)
+ / = # 图片手动放大 15%
- # 图片手动缩小
0 # 重置图片缩放为最佳贴合尺寸
Ctrl+e / Ctrl+y # 预览窗口下滚/上滚 5 行
B / b # 在 Mac 浏览器中通过 HTTP 原地打开当前文件(可发同事,按 Ctrl+C 秒回 Yazi)
# --- Tmux 窗口与分屏操作 (远程服务器,前缀键统一为 Ctrl+b,完全释放 Ctrl+a 跳行首) ---
Ctrl+b 然后按 } # 当前窗口与右侧/下方窗口互换位置!
Ctrl+b 然后按 { # 当前窗口与左侧/上方窗口互换位置!
Ctrl+b 然后按 Ctrl+o # 顺时针旋转轮换所有分屏
Ctrl+b 然后按 Space # 一键切换分屏排版(左右、上下、网格四分屏)
Ctrl+b 然后按 ! # 把当前分屏弹射出去变成独立的新全屏窗口
Ctrl+b 然后按 z # 当前分屏临时最大化 / 还原