1616 字
8 分钟
给 remote-ssh 加上 glob 支持:scp 通配符的正确姿势

用 Claude Code 干活的时候,经常需要往服务器上传文件。我之前写了个 remote-ssh skill,把 SSH 操作封装成一组短命令——rcmd 执行远程命令,rput 上传,rget 下载,挺好用的。

直到今天我想把一堆 .py 文件传上去。

Terminal window
rput *.py /remote/scripts/

报错了。rput 只接受两个参数:一个本地路径,一个远程路径。shell 把 *.py 展开成了 a.py b.py c.py /remote/scripts/,四个参数,直接被参数检查挡回来了。

行吧,改。


原来的实现#

原版 rput 极其简单:

rput (v2.0)
if [ $# -ne 2 ]; then
echo "Usage: rput <local_path> <remote_path>"
exit 1
fi
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 改成这样:

rput (v2.1)
# 最后一个参数是远程目标
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 必须加引号

Terminal window
# 错误 - shell 在本地展开 *.log,找不到匹配就报错
rget /var/log/*.log ./logs/
# 正确 - 引号阻止本地展开,glob 传到远程去展开
rget '/var/log/*.log' ./logs/

所以 rget 的实现里,我用引号保护了远程路径:

rget (v2.1)
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 会自动创建以 / 结尾的本地目录:

Terminal window
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 协议:

Terminal window
scp -O "host:/tmp/*.log" ./

我在 macOS 上测试没有遇到这个问题,但值得记一笔。


测试#

mini 服务器上跑了一轮完整测试。

先创建测试文件:

Terminal window
echo "test1" > test_a.txt
echo "test2" > test_b.txt
echo "test3" > test_c.txt

glob 上传

Terminal window
RHOST=mini rput test_*.txt /tmp/glob_test/

检查远程:

Terminal window
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 下载

Terminal window
RHOST=mini rget '/tmp/glob_test/test_*.txt' ./downloaded/

本地检查:

Terminal window
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

多文件指定下载

Terminal window
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。

原来的逻辑是这样的:

rput v2.1 第一版(有 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,拆成 myfile.txt 两个参数,scp 直接炸。

而且这个 if/else 本身就是多余的——scp "${local_files[@]}" 不管数组里有一个元素还是十个,行为完全一样。分支只是增加了出 bug 的面积。

另外还有个隐蔽问题:我用 unset 'args[$#-1]' 来删除最后一个元素,这会让 bash 数组变成 sparse array(中间有洞)。虽然在这个场景下不会出问题,但不如用 slice 干净:

Terminal window
remote="${@: -1}"
local_files=("${@:1:$#-1}")

rget 那边也有可以精简的地方。原来用 for 循环给每个远程路径加 host 前缀:

rget 第一版
remote_sources=()
for rpath in "${remote_paths[@]}"; do
remote_sources+=("$HOST:$rpath")
done
scp "${remote_sources[@]}" "$local_dest"

bash 有个数组替换语法可以一行搞定:

rget 清理后
scp "${remote_paths[@]/#/$HOST:}" "$local_dest"

${array[@]/#/prefix} 会给数组每个元素开头加上 prefix。不需要循环,不需要临时变量。

还有个小问题:mkdir -p 前面的 [ ! -d ] 检查是多余的。mkdir -p 本身就是幂等的——目录存在不报错,不存在就创建。加个存在性检查反而引入了 TOCTOU race condition(虽然这里实际不会出问题,但没必要写冗余代码)。

清理后的最终版本:

rput(最终版)
#!/bin/bash
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: rput <local_path(s)...> <remote_path>"
exit 1
fi
HOST="${RHOST:-do}"
remote="${@: -1}"
local_files=("${@:1:$#-1}")
scp "${local_files[@]}" "$HOST:$remote"
rget(最终版)
#!/bin/bash
set -euo pipefail
if [ $# -lt 2 ]; then
echo "Usage: rget <remote_path(s)...> <local_path>"
exit 1
fi
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 remoterget remote file 的两参数用法完全不受影响。

给 remote-ssh 加上 glob 支持:scp 通配符的正确姿势
https://blog.lishuyu.top/posts/remote-ssh-glob-scp-wildcard/
作者
猫猫魔女
发布于
2026-04-08
许可协议
CC BY-NC-SA 4.0