用 Claude Code 干活的时候,经常需要往服务器上传文件。我之前写了个 remote-ssh skill,把 SSH 操作封装成一组短命令——rcmd 执行远程命令,rput 上传,rget 下载,挺好用的。
直到今天我想把一堆 .py 文件传上去。
rput *.py /remote/scripts/报错了。rput 只接受两个参数:一个本地路径,一个远程路径。shell 把 *.py 展开成了 a.py b.py c.py /remote/scripts/,四个参数,直接被参数检查挡回来了。
行吧,改。
原来的实现
原版 rput 极其简单:
if [ $# -ne 2 ]; then echo "Usage: rput <local_path> <remote_path>" exit 1fi
HOST="${RHOST:-do}"scp "$1" "$HOST:$2"rget 也是一样的结构,固定两个参数。这在传单个文件的时候没问题,但现实中你经常需要:
rput *.log /remote/logs/— 把当前目录所有日志传上去rget '/var/log/*.log' ./debug/— 把远程的日志全拉下来rput config.yaml main.py /remote/app/— 一次传多个指定文件
这三种场景,老版本一个都搞不定。
改造思路
核心变化很简单:最后一个参数是目标路径,前面的都是源文件。
rput 改成这样:
# 最后一个参数是远程目标args=("$@")remote="${args[$#-1]}"unset 'args[$#-1]'local_files=("${args[@]}")
if [ ${#local_files[@]} -gt 1 ]; then scp "${local_files[@]}" "$HOST:$remote"else scp ${local_files[0]} "$HOST:$remote"fi注意单文件的时候 ${local_files[0]} 没有加引号。这是故意的——如果用户写 rput *.py /remote/,shell 在调用 rput 之前就已经把 *.py 展开了,到脚本里 $@ 已经是展开后的多个文件。但如果用户传的是一个带空格的文件名,引号会保护它。单文件不加引号是为了兼容两种情况。
等等,这里其实有个微妙的点。
scp 的 glob 到底怎么工作的
这里有个容易搞混的地方:本地 glob 和远程 glob 的工作方式完全不同。
本地 glob:shell 负责展开。你写 scp *.py host:/tmp/,shell 先把 *.py 展开成 a.py b.py c.py,然后 scp 看到的是三个文件参数。scp 本身不做 glob 展开。
远程 glob:scp 把路径发给远程 shell 去展开。你写 scp host:/tmp/*.log ./,scp 会让远程机器的 shell 展开 /tmp/*.log,然后把匹配的文件传回来。
这就引出了一个关键问题:远程 glob 必须加引号。
# 错误 - shell 在本地展开 *.log,找不到匹配就报错rget /var/log/*.log ./logs/
# 正确 - 引号阻止本地展开,glob 传到远程去展开rget '/var/log/*.log' ./logs/所以 rget 的实现里,我用引号保护了远程路径:
remote_sources=()for rpath in "${remote_paths[@]}"; do remote_sources+=("$HOST:$rpath")done
scp "${remote_sources[@]}" "$local_dest""${remote_sources[@]}" 的双引号保护了每个元素不被二次展开,但 scp 拿到 host:/var/log/*.log 后会把它发给远程 shell,远程 shell 会做 glob 展开。
还有个小细节:rget 会自动创建以 / 结尾的本地目录:
if [[ "$local_dest" == */ ]] && [ ! -d "$local_dest" ]; then mkdir -p "$local_dest"fi这样 rget '/remote/*.log' ./new-dir/ 不用提前手动建目录。
一个值得注意的坑
查资料的时候发现,新版 OpenSSH(8.0+)的 scp 默认切换到了 SFTP 协议。SFTP 对远程 glob 的支持比较有限——有些版本下远程通配符可能不工作。
如果你遇到远程 glob 失败,可以加 -O 参数强制使用传统 SCP 协议:
scp -O "host:/tmp/*.log" ./我在 macOS 上测试没有遇到这个问题,但值得记一笔。
测试
在 mini 服务器上跑了一轮完整测试。
先创建测试文件:
echo "test1" > test_a.txtecho "test2" > test_b.txtecho "test3" > test_c.txtglob 上传:
RHOST=mini rput test_*.txt /tmp/glob_test/检查远程:
RHOST=mini rcmd "ls -la /tmp/glob_test/"-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_a.txt-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_b.txt-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_c.txt三个文件都到了。
glob 下载:
RHOST=mini rget '/tmp/glob_test/test_*.txt' ./downloaded/本地检查:
ls -la ./downloaded/-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_a.txt-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_b.txt-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_c.txt多文件指定下载:
RHOST=mini rget /tmp/glob_test/test_a.txt /tmp/glob_test/test_c.txt ./downloaded2/-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_a.txt-rw-r--r-- 1 lishuyu wheel 6 Apr 8 17:46 test_c.txt只拉了指定的两个,符合预期。全部通过。
写完第一版就发现了 bug
功能跑通之后我习惯性做了一轮 code review,结果发现第一版 rput 里藏了个 bug。
原来的逻辑是这样的:
if [ ${#local_files[@]} -gt 1 ]; then scp "${local_files[@]}" "$HOST:$remote"else scp ${local_files[0]} "$HOST:$remote"fi看出来了吗?单文件分支里 ${local_files[0]} 没加引号。如果文件名带空格,比如 my file.txt,shell 会做 word splitting,拆成 my 和 file.txt 两个参数,scp 直接炸。
而且这个 if/else 本身就是多余的——scp "${local_files[@]}" 不管数组里有一个元素还是十个,行为完全一样。分支只是增加了出 bug 的面积。
另外还有个隐蔽问题:我用 unset 'args[$#-1]' 来删除最后一个元素,这会让 bash 数组变成 sparse array(中间有洞)。虽然在这个场景下不会出问题,但不如用 slice 干净:
remote="${@: -1}"local_files=("${@:1:$#-1}")rget 那边也有可以精简的地方。原来用 for 循环给每个远程路径加 host 前缀:
remote_sources=()for rpath in "${remote_paths[@]}"; do remote_sources+=("$HOST:$rpath")donescp "${remote_sources[@]}" "$local_dest"bash 有个数组替换语法可以一行搞定:
scp "${remote_paths[@]/#/$HOST:}" "$local_dest"${array[@]/#/prefix} 会给数组每个元素开头加上 prefix。不需要循环,不需要临时变量。
还有个小问题:mkdir -p 前面的 [ ! -d ] 检查是多余的。mkdir -p 本身就是幂等的——目录存在不报错,不存在就创建。加个存在性检查反而引入了 TOCTOU race condition(虽然这里实际不会出问题,但没必要写冗余代码)。
清理后的最终版本:
#!/bin/bashset -euo pipefail
if [ $# -lt 2 ]; then echo "Usage: rput <local_path(s)...> <remote_path>" exit 1fi
HOST="${RHOST:-do}"remote="${@: -1}"local_files=("${@:1:$#-1}")
scp "${local_files[@]}" "$HOST:$remote"#!/bin/bashset -euo pipefail
if [ $# -lt 2 ]; then echo "Usage: rget <remote_path(s)...> <local_path>" exit 1fi
HOST="${RHOST:-do}"local_dest="${@: -1}"remote_paths=("${@:1:$#-1}")
[[ "$local_dest" == */ ]] && mkdir -p "$local_dest"
scp "${remote_paths[@]/#/$HOST:}" "$local_dest"每个脚本的核心逻辑就三四行。比第一版少了将近一半的代码,还修掉了空格文件名的 bug。
总结
改动不大,但日常用起来方便很多。要点:
- 本地 glob:shell 展开,脚本处理多参数就行
- 远程 glob:必须加引号,让远程 shell 展开
- 新版 OpenSSH:如果远程 glob 失效,试试
scp -O - 目录自动创建:
rget会自动mkdir -p以/结尾的目标路径 - 写完就 review:第一版跑通不等于没 bug,unquoted expansion 这种问题只有 review 才能抓到
remote-ssh skill 从 v2.0 升到了 v2.1,向后兼容——原来 rput file remote 和 rget remote file 的两参数用法完全不受影响。