闭着眼也能走完的那几步

下载安装包,双击,下一步、下一步、完成。这个流程你闭着眼都能走完,因为走过太多次了,而且中间没有一步需要你做判断。装完之后,C:\Program Files\某个厂商\某个产品\ 下多了一个目录,注册表里多出一条卸载项,PATH 里多出一行。装完就把窗口关了,没有一处告诉你文件落在哪。你点开开始菜单,图标在那儿,能用,结束。

装完之后,你不知道它做了什么

装完之后,你不知道它把文件放哪了、动了哪些系统设置、卸的时候能不能删干净。

平时这些不重要,能点开就行。等到要换电脑、要重装系统、要改某个配置、要清一块越来越小的系统盘,它们就一起变成问题,而且是没有答案的那类问题。你去问软件本身,它不会告诉你。

比如卸完一个软件,Program Files 下那个目录还在,剩几个空文件夹;再装一次同名的新版本,两个目录并排站着,谁也说不清哪个是活的。你只能在硬盘上一个个目录翻,翻到一半开始怀疑是不是自己记错了。

它把地址写进了别人家里

说具体一点。Node 我是用 MSI 装的,默认落在 C:\Program Files\nodejs,装的时候没什么可选的。麻烦在于,装完以后所有要引用它的地方,都把这条路径抄了一遍,写死在自己的配置里。

后来我把 Node 挪了位置,~/.claude/settings.json 里那行写死的解释器路径就成了一个死地址。状态栏整行消失,命令退出码 127。不是插件坏了,是它要调用的那个 node 不在原地了。

我当时的排查顺序是反的:先怀疑插件,再怀疑版本,最后才想到去看它到底调的是哪个可执行文件。这一步只需要一眼。安装器本身没做错什么,它只是把文件铺开,然后留下一条条指向这些文件的线索,线索散在各处,没人负责回收。

安装器最不负责的地方,是它走了以后,别人还在按它留的地址找人。

一台机器上三份 Python

另一类更隐蔽。我这台机器上有三份 Python、两份 Node,PATH 里躺着四十多条,其中一半指向早就删掉的目录。我认真往下翻过一遍,有几条指的还是已经不存在的盘。它们静静地待在那儿,占着位置,也不报错。

PATH 像一份手写的门牌列表,系统从上往下挨个敲门,敲到有人应就算数——落到程序上,就是「哪个先被找到就用哪个」。这条街上空着几户、先敲的那户是不是早就搬走,它一概不管。

PATH 示例

说回工具本身。Scoop 是 Windows 上的命令行包管理器,靠解压和目录链接装软件。之前写过一篇怎么挑工具,讲的是把东西装进来;这篇不一样,装到 79 个之后,真正难的是怎么知道它到底有没有生效。

一次安装,没有安装程序

你敲下 scoop install 的那一刻,Scoop 做的事很朴素。它先去桶(bucket,一个装满清单的 git 仓库)里读那份清单,从清单写的地址把文件下载下来,算一遍 hash 跟你对一下;再解压到 apps\<包名>\<版本>\ 这个带版本号的目录,把 apps\<包名>\current 指向这个新目录;接着按清单的声明生成入口。

五步,没有一步会跳出窗口问你「是否同意许可协议」。它不要管理员权限,也不往注册表里写卸载项——你在「程序和功能」里根本找不到它装过的东西。

它没有安装程序,所以也没有安装程序那些偷偷摸摸的动作。

目录结构决定行为。apps\<包名>\<版本>\ 是真身,current 只是指向当前那个版本的一个指针。升级能做到并存就是这个原因:新版本解压进一个新目录,current 一改就切过去,旧的那份还躺在原地,直到你跑 cleanup 才回收。update 从来不删旧版本。我有一次清出来,13 个包压了大约 2.5 GB,全是旧版本目录。

apps 版本目录

跟 winget、Chocolatey 的差别也在这五步里。winget 调的是软件厂商自己的安装器,装完还是 MSI/EXE 那一套;Chocolatey 默认装在机器级目录,脚本以管理员身份跑;Scoop 是用户级、解压式,整个目录能搬走。代价是凡要写注册表、装驱动、注册服务的软件,它要么装不了,要么只能走 nonportable 桶(它装的是厂商官方安装包,只是帮你把下载和校验包了)。

清单是份 JSON,而 hash 只保护下载物

清单就是一份 JSON,字段少得可怜,信息量却不小:version 说版本,url 说去哪下,hash 说下下来的东西对不对,bin、env_add_path、shortcuts 说入口怎么给,persist 说哪些东西要留下,depends 说依赖谁。

这里有个容易误会的地方。hash 保护的是你下载下来的那个文件,校验的是它没被人动过手脚。

清单里带安装脚本是常事。脚本跑起来做什么,hash 管不到。官方桶的清单基本只做解压,社区桶就不一定了。

入口有三种,装好了不等于敲得出来

入口这层最容易出岔子,因为它不止一种形式。

bin 是生成 shim。shim 就是一个几十 KB 的小 exe,你敲命令时它先启动,去读 current 指向哪,再把参数原样转给那边那个程序。这一跳只发生在进程里,输出的最后一行已经是结果了,current 一旦没指对,命令就落空。

env_add_path 不生成 shim,直接把某个目录塞进 PATH。nodejs24 走的就是这条,它的东西不在 shims 里,你按包名去那儿找永远找不到。

shortcuts 只建一个开始菜单快捷方式,命令行里完全没有对应物。translucenttb 属于这类,只能从开始菜单点开。

同一份清单可能只用一种,也可能几种混着。哪天你对着一堆包敲不出命令,先别急着怀疑装失败,想想它当初用的是哪种入口。

升级时唯一活下来的目录

persist 是整套机制里最聪明的一处。升级时整个版本目录会被换掉,只有 persist\<包名>\ 保留下来,再链接回新版本。像一次搬家:旧房子整个清空,而 persist 是你允许它带走的那个箱子,配置放进去就丢不了。

前提是这个包自己声明了它。没声明的包,升级会把配置一起换掉。poedit 就没声明,我升完级才发现设置全回到了默认。

先把数字数清楚

配置丢了才知道自己没盯住,所以我先把自己的家底数清楚。我这台机器上现在是 79 个包、14 个桶。这两个数是 scoop export 输出 JSON、再数 apps 数组长度得来的。

非得这么数,是因为 scoop list 那张表是给人看的。它会折行、会截断,还会多出几条幽灵记录——名字还在,东西早没了。照着屏幕数行数,数出来的跟实际装的不是一个东西。

79 这个数字我数错过三次,每次都是因为信了屏幕上那几行表格。后来我养成个习惯:要报数就去读 JSON,别读表格。这两个数成了我的基准,后来每次报数都拿它核一遍。

这件事看着小,却是我后来判断所有 Scoop 问题的底子:屏幕上的数,和磁盘上的数,往往对不上。往后的麻烦大半就长在这条缝里。

数出来的 79 里,还有一两个不是我主动装的。装 poedit 时,因为它标了 innosetup,Scoop 自动装上了 innounp 来解包,两者相差 19 秒。包数突然加一,不一定是你装的。

十四个桶,官方十个,社区四个

每个桶都是这样一个清单仓库,scoop update 本质上做的就是 git pull,把每个桶的清单拉到最新。

十四个桶里,官方十个:main、extras、versions、nirsoft、sysinternals、php、nerd-fonts、nonportable、java、games;社区四个:dorado、scoopet、type,外加我自己维护的 lhy。体量差得远,光 extras 一个桶就有 2451 条清单。桶多不等于包多,我天天翻的其实就 main 和 extras 这两个。

桶里装的全是 JSON 清单。换镜像,本质上换的是这批清单的新鲜度。

换镜像那天,我只比了 commit

2026-10-14,我把官方那十个桶全换成了南京大学镜像,地址是 https://mirror.nju.edu.cn/git/scoop/<仓库名>.git。仓库名带前缀、桶名不带,比如 games 对应的是 scoop-games。

换之前逐桶比了 commit,结论是南大 ≥ Gitee:跟上游一致的桶更多,而且没有任何一个桶是 Gitee 领先。所以准确说法是「不比 Gitee 差」,不是「实时同步」——versions 和 games 两边停在同一个落后的提交上。

换法上我只改 git remote,没 bucket rm 再 add。理由可验证:桶的来源 URL 存在本地 clone 的 git remote 里,桶名不变,已装包的来源记录就不受影响。这个动作小到不值得截图,却是我少数几次先把结果想清楚再动手的场合。

社区桶走不了这条路,实测 404。dorado、scoopet、type 留在原源,lhy 是我自己的 GitHub 仓库,照旧。这条差异不是小事,只是当时我没往深处想。

整棵树都能拷走

Scoop 的根目录在 D:\settings\settings\Scoop,进去看一层就够。apps 放包的真身和各版本目录,persist 放升级时唯一被留下来的配置,shims 放那堆命令入口,buckets 放清单仓库,cache 是下载缓存——你把包事先丢进去,它下次就跳过下载。

各管一摊,合起来就是一个自包含的软件环境,整个目录拷到另一台机器,装的东西跟着走。

Scoop 根目录

它什么时候不工作

数字对不上只是最小的那种对不上。真正麻烦的是命令本身:它跑了,返回 0,事情没做成。我把自己踩过的坑排了一遍,表面和实际对不上的地方长相都差不多,沉默的方式不一样,结果一样。命令没做错,是我对它的预期错了。

scoop update 不更新应用

我一直以为自己每周在维护这台机器。scoop update 跑得挺顺,输出里一屏哈希、一行行刷新的桶,看着像在干活。后来随手敲了某个工具的新参数,提示不存在,才发现它的版本停在几个月前。

不带参数的 scoop update,只更新 Scoop 自身和所有桶,一个应用都不碰。它刷的是这个桶的清单——「有哪些新版本可以装」,不是「把新版本装上」。真要更新应用,得写 scoop update *,或者指名到具体包。

这个设计本身不坏,刷新清单是安全的,不会在你赶 deadline 的晚上把编辑器换掉。问题在于输出不提醒你:它把桶刷了一遍,安静地结束,没有一句「本次未更新任何应用」。

别看 update 跑没跑,去看 scoop status 列出的待更新项,或者直接问包自己。

桶里的未提交改动,会让整个桶静默停更

我踩过一个坑,代价是两天。lhy 桶里有一处大小写修正没提交,我一直以为上游还没发 patheditor 5.1.4,等了两个晚上。实际是本地的桶卡住了。

Scoop 拉桶执行的是 git pull -q。桶目录里只要有未提交改动,而本机 git 配了 pull.rebase = true,rebase 对未提交改动零容忍:报错,跳过这个桶,继续下一个,整个流程不中断。Scoop 不会因为一个桶失败就整体停下,它把失败当成这一桶自己的事,其余照常刷完,然后正常退出。

后果是这整桶的清单一起停在原地,不只是某个包。scoop status 读的就是那份旧清单,自然报不出新版本。别的桶都在动,只有这一个不动,表面上完全正常,很容易误判成网络问题,或者上游偷懒。

排查两条命令:git -C "Scoop\buckets\<桶名>" status --short,再 git log HEAD..origin/master --oneline 看欠了多少提交。修复是 stash、pull、stash pop。

bucket 状态检查

Install failed 不是「安装失败」

scoop list 里有一行 Install failed,在 aria2 身上挂了好久。我一度真以为它装坏了。

那行字说的是「在版本目录下找不到安装档案」,读取失败,跟安装失败没关系。我这份是旧布局留下的:版本目录是个空目录,current 却是真实目录,本体躺在里面。它不影响使用,只影响列表里怎么显示。Scoop 按新布局去读,自然读不到,而 aria2c --version 一直是好的。

反面也得说。codex 有一次是真失败:空目录、没有 shim、命令敲不出来。两种 Install failed 在列表里长得一模一样。

重装当然能解决,但在动手之前,先确认它到底是不是真坏。

先看它能不能跑,再看它怎么说自己。

Install failed 与版本对照

敲 oh-my-pi 永远没有反应

包名只是它在桶里的标识,bin 才是它对外承诺的命令名,两套名字经常对不上。shim 的名字来自清单里的 bin 字段,geekuninstaller 的命令叫 geek,pi-coding-agent 叫 pi,oh-my-pi 叫 omp,你敲 oh-my-pi 永远出不来,因为那个 shim 压根不存在。

反过来还有一类:xnconvert、translucenttb、patheditor-gui、deepseek-harness-pre 都不生成 shim,只能从开始菜单启动。nodejs24 更特别,它靠 env_add_path 把目录塞进 PATH。这几个里有的是 GUI 程序,本来就没有命令行入口,装完敲不出来是正常的,别急着怀疑安装。

scoop which <包名> 返回警告,不代表没装。这时去 Scoop\shims\ 里列一遍,或者翻开始菜单的 Scoop 程序组。判断一个包到底有没有入口,看清单顶部那个 bin 字段,比在命令行里猜快得多。

同名冲突也看它:patheditor-cli 和 patheditor-gui 都带 patheditor.exe,但 gui 版清单里没有 bin,命令唯一归属 CLI 版。

shims 目录

搜不到,说明你搜的方式有问题

搜不到就当不存在,我因此下过一个错误结论。

scoop search 匹配的是包名,加上清单里 bin 声明的可执行名。它不搜描述,而且只搜你已经添加的桶。所以 procexp 搜不到 process-explorer;cc-switch 的描述里写着 all-in-one,你拿 scoop search "all-in-one" 去搜,一条都没有。搜索给的是候选,全集得去翻桶里的文件,它会截断,也会因为你少打一个词就漏掉。

查询还按正则解析,词太短会被淹没。pi 命中 132 条,加个锚点 ^pi 只剩 38 条。这一下省的是翻结果的时间。搜索能帮你缩小范围,判断还得靠桶里的文件。

我那个结论是两个错叠在一起:结果被截断,加上拿精确文件名去找一个复合包名。可靠的兜底是绕开搜索,直接翻 Scoop\buckets\<桶名>\bucket\ 下的 JSON 文件。翻文件慢一点,但那里是全集,包名、版本、bin 都写在里面。

aria2 的红字,多半是假警报

Scoop 的下载默认走 aria2。本机到 GitHub 不稳,它经常在握手那一步栽,然后回落内置下载器。反过来也有:它红着脸报错,事情其实做成了。

装 uv 的时候,它连着报两次 SSL/TLS handshake failure,满屏红色。装 herdr 也是先报握手失败,然后 9.6 MB 下完,hash 校验通过。两次我都差点去关 aria2。

本机到 GitHub 间歇性不稳,aria2 握手失败之后会自动回落内置下载器,照样把包装完、照样过 hash。回落这件事它也不大声宣布,只在日志里留一行,滚得快一点就看不见了。红色是真的,但它不代表结果。

等它跑完,再看 --version 能不能输出,比盯那几行红字有用。真失败会留下痕迹:一个空目录,加上 shims 里找不到入口。没这两样,那条红字就是过程噪音。

社区桶的信任边界:hash 保不了安装脚本

社区桶的风险不在安装失败这一层。它装得上,也能用,只是清单里那几行脚本,没人替你审。官方桶我基本闭眼装,社区桶我会点开清单看一眼,差别就在这儿。

scoopet 那 90 多条清单里,有 50 条带 pre_install、post_install 或者 installer。社区桶不是不能用,只是「看一眼」的成本得你自己付。

装社区桶的包之前,先打开 Scoop\buckets\scoopet\bucket\<包名>.json 看一眼,重点不是 hash,是那几行命令写了什么。成本很低,看清它调了什么、往哪儿写,再决定装不装。

同样是装一个工具

以前装一个工具,走完安装向导,我照样不知道它落在哪。现在换机器、重装系统,我要面对的只剩一个目录:D:\settings\settings\Scoop。包的本体在 apps,要留下来的配置在 persist,入口在 shims,清单在 buckets。

整棵树拷到 D 盘另一个位置,包的本体不会丢。但 shim 和 PATH 里那几条都是按安装时的路径记的,挪完得重新生成一遍。开头那个被写死的 node 路径,在这里没有对应的麻烦,因为没有任何一处需要我记住它落在哪。

话得说完整。包的本体确实在这一个目录里,系统盘干净了不少,但不少包照样往 %APPDATA% 和用户目录写东西。herdr 的配置和 socket 在那儿,各家 AI CLI 的数据目录也在那儿。「系统盘只读」是个方向,不是已经完成的事实。这条边界知道就行,不用跟它较劲。

一张 JSON,79 条

要把这台机器搬走,跑一次 scoop export 就行。它输出一份 JSON,apps 数组里 79 条,每条都带名字、版本、来源桶。哪天重建,照着它一条条来就行,来源和版本都对得上,不用再开一堆网页比对。

scoop export 输出

我把这份 JSON 扔进了网盘。它比我自己记的那份包名单准,因为里面没有一条是凭印象写上去的。

这一年下来,我形成的判断只有一条:list 说失败,不代表安装失败;update 跑完,不代表应用更新了;搜不到,不代表不存在。遇到「怎么又不行了」,先验证现象,再下结论。

命令返回 0,只说明它跑完了,不说明它做成了。

现在我判断一个工具装没装好,第一件事是敲它的命令,看它能不能应一声。应不了,再去翻目录。